diff --git a/docs/unimelb-mf-upload.md b/docs/unimelb-mf-upload.md
index 5621b297a22d9493429095896007fe264e53a319..87119fc9d9c72fd166bd6463fb1edfbd1aaab915 100644
--- a/docs/unimelb-mf-upload.md
+++ b/docs/unimelb-mf-upload.md
@@ -32,5 +32,5 @@ POSITIONAL ARGUMENTS:
     <src-dir>                                 Source directory to upload.
 
 EXAMPLES:
-    unimelb-mf-upload --mf.config ~/.Arcitecta/mflux.cfg --nb-queriers 2 --nb-workers 4  --namespace /projects/proj-1128.1.59 ~/Documents/foo ~/Documents/bar
+    unimelb-mf-upload --mf.config ~/.Arcitecta/mflux.cfg --nb-workers 4  --namespace /projects/proj-1128.1.59 ~/Documents/foo ~/Documents/bar
 ```
diff --git a/pom.xml b/pom.xml
index fa8a9b6c63153dc64f7bad33d83441ac8e284605..bb251ffc94f6cff83fbeaf9d2fec840b73cd836e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,16 +5,22 @@
 
 	<groupId>au.edu.unimelb.mf</groupId>
 	<artifactId>unimelb-mf-clients</artifactId>
-	<version>0.1.9</version>
+	<version>0.2.0</version>
 	<packaging>jar</packaging>
 	<name>unimelb-mf-clients</name>
-	<url>https://github.com/UoM-ResPlat-DevOps/unimelb-mf-clients</url>
+	<url>https://gitlab.unimelb.edu.au/resplat-mediaflux/unimelb-mf-clients</url>
 	<description>UniMelb Mediaflux clients.</description>
 	<properties>
 		<maven.compiler.source>1.8</maven.compiler.source>
 		<maven.compiler.target>1.8</maven.compiler.target>
 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+		<skipTests>true</skipTests>
 		<mexplorer.version>1.3.27</mexplorer.version>
+		<jre8mac64file>jre-8u191-macosx-x64</jre8mac64file>
+		<jre8mac64url>https://download.oracle.com/otn-pub/java/jdk/8u191-b12/2787e4a523244c269598db4e85c51e0c/jre-8u191-macosx-x64.tar.gz</jre8mac64url>
+		<jre8win64file>jre-8u191-windows-x64</jre8win64file>
+		<jre8win64url>https://download.oracle.com/otn-pub/java/jdk/8u191-b12/2787e4a523244c269598db4e85c51e0c/jre-8u191-windows-x64.tar.gz</jre8win64url>
+		<dist>${project.build.directory}/dist</dist>
 	</properties>
 	<repositories>
 		<repository>
@@ -43,23 +49,22 @@
 	</dependencies>
 	<build>
 		<plugins>
+
 			<plugin>
-				<groupId>org.apache.maven.plugins</groupId>
-				<artifactId>maven-antrun-plugin</artifactId>
-				<version>1.7</version>
+				<groupId>org.codehaus.mojo</groupId>
+				<artifactId>wagon-maven-plugin</artifactId>
+				<version>1.0</version>
 				<executions>
 					<execution>
-						<phase>package</phase>
 						<id>download-mexplorer</id>
+						<phase>prepare-package</phase>
 						<goals>
-							<goal>run</goal>
+							<goal>download-single</goal>
 						</goals>
 						<configuration>
-							<target>
-								<get
-									src="http://www.arcitecta.com/software/mexplorer/mexplorer-${mexplorer.version}.jar"
-									dest="${project.build.directory}" />
-							</target>
+							<url>http://www.arcitecta.com</url>
+							<fromFile>/software/mexplorer/mexplorer-${mexplorer.version}.jar</fromFile>
+							<toFile>${project.build.directory}/mexplorer.jar</toFile>
 						</configuration>
 					</execution>
 				</executions>
@@ -108,6 +113,148 @@
 					</execution>
 				</executions>
 			</plugin>
+			<plugin>
+				<artifactId>maven-antrun-plugin</artifactId>
+				<version>1.8</version>
+				<executions>
+					<execution>
+						<phase>package</phase>
+						<configuration>
+							<target>
+								<!-- Initialisation -->
+								<tstamp />
+								<delete dir="${dist}" />
+								<mkdir dir="${dist}" />
+
+								<!-- Mac OS package -->
+								<echo
+									message="building Mac OS package with 64 bit Java 8 Runtime..." />
+
+								<mkdir dir="${dist}/mac" />
+								<mkdir dir="${dist}/mac/temp" />
+								<mkdir
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}" />
+								<mkdir
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}/bin" />
+								<mkdir
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}/bin/unix" />
+								<mkdir
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}/lib" />
+								<copy
+									file="${project.build.directory}/unimelb-mf-clients-${project.version}-jar-with-dependencies.jar"
+									tofile="${dist}/mac/temp/unimelb-mf-clients-${project.version}/lib/unimelb-mf-clients.jar" />
+								<copy file="${project.build.directory}/mexplorer.jar"
+									tofile="${dist}/mac/temp/unimelb-mf-clients-${project.version}/lib/mexplorer.jar" />
+								<copy
+									todir="${dist}/mac/temp/unimelb-mf-clients-${project.version}/bin/unix">
+									<fileset dir="${project.basedir}/src/main/scripts/unix" />
+								</copy>
+								<exec executable="curl" dir="${dist}/mac">
+									<arg value="-L" />
+									<arg value="-O" />
+									<arg value="-H" />
+									<arg
+										value="Cookie: oraclelicense=accept-securebackup-cookie" />
+									<arg value="-k" />
+									<arg value="${jre8mac64url}" />
+								</exec>
+								<gunzip src="${dist}/mac/${jre8mac64file}.tar.gz"
+									dest="${dist}/mac" />
+								<untar src="${dist}/mac/${jre8mac64file}.tar"
+									dest="${dist}/mac/temp/unimelb-mf-clients-${project.version}" />
+								<dirset
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}"
+									id="jreDirId">
+									<include name="jre*" />
+								</dirset>
+								<property name="jreDir" refid="jreDirId" />
+								<property name="javaHome"
+									value="${jreDir}/Contents/Home" />
+								<replace
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}/bin/unix"
+									token="@JAVA_HOME@" value="${javaHome}" />
+								<replace
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}/bin/unix"
+									token="#export JAVA_HOME=" value="export JAVA_HOME=" />
+								<replace
+									dir="${dist}/mac/temp/unimelb-mf-clients-${project.version}/bin/unix"
+									token="#export PATH=" value="export PATH=" />
+								<zip
+									destfile="${dist}/mac/unimelb-mf-clients-${project.version}-mac-x64.zip">
+									<zipfileset dir="${dist}/mac/temp" filemode="755" />
+								</zip>
+								<delete dir="${dist}/mac/temp" />
+								<delete file="${dist}/mac/${jre8mac64file}.tar.gz" />
+								<delete file="${dist}/mac/${jre8mac64file}.tar" />
+
+								<!-- Windows package -->
+								<echo
+									message="building Windows package with 64 bit Java 8 Runtime..." />
+								<mkdir dir="${dist}/windows" />
+								<mkdir dir="${dist}/windows/temp" />
+								<mkdir
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}" />
+								<mkdir
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}/bin" />
+								<mkdir
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}/bin/windows" />
+								<mkdir
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}/lib" />
+								<copy
+									file="${project.build.directory}/unimelb-mf-clients-${project.version}-jar-with-dependencies.jar"
+									tofile="${dist}/windows/temp/unimelb-mf-clients-${project.version}/lib/unimelb-mf-clients.jar" />
+								<copy file="${project.build.directory}/mexplorer.jar"
+									tofile="${dist}/windows/temp/unimelb-mf-clients-${project.version}/lib/mexplorer.jar" />
+								<copy
+									todir="${dist}/windows/temp/unimelb-mf-clients-${project.version}/bin/windows">
+									<fileset
+										dir="${project.basedir}/src/main/scripts/windows" />
+								</copy>
+								<exec executable="curl" dir="${dist}/windows">
+									<arg value="-L" />
+									<arg value="-O" />
+									<arg value="-H" />
+									<arg
+										value="Cookie: oraclelicense=accept-securebackup-cookie" />
+									<arg value="-k" />
+									<arg value="${jre8win64url}" />
+								</exec>
+								<gunzip src="${dist}/windows/${jre8win64file}.tar.gz"
+									dest="${dist}/windows" />
+								<untar src="${dist}/windows/${jre8win64file}.tar"
+									dest="${dist}/windows/temp/unimelb-mf-clients-${project.version}" />
+								<dirset
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}"
+									id="jreDirId">
+									<include name="jre*" />
+								</dirset>
+								<property name="jreDir" refid="jreDirId" />
+								<property name="javaHome" value="${jreDir}" />
+								<replace
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}/bin/windows"
+									token="@JAVA_HOME@" value="${javaHome}" />
+								<replace
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}/bin/windows"
+									token="@REM set JAVA_HOME=" value="set JAVA_HOME=" />
+								<replace
+									dir="${dist}/windows/temp/unimelb-mf-clients-${project.version}/bin/windows"
+									token="@REM set PATH=" value="set PATH=" />
+								<zip
+									destfile="${dist}/windows/unimelb-mf-clients-${project.version}-windows-x64.zip">
+									<zipfileset dir="${dist}/windows/temp"
+										filemode="755" />
+								</zip>
+								<delete dir="${dist}/windows/temp" />
+								<delete file="${dist}/windows/${jre8win64file}.tar.gz" />
+								<delete file="${dist}/windows/${jre8win64file}.tar" />
+							</target>
+						</configuration>
+						<goals>
+							<goal>run</goal>
+						</goals>
+					</execution>
+				</executions>
+			</plugin>
 		</plugins>
 	</build>
 </project>
diff --git a/src/main/assembly/make-zip.xml b/src/main/assembly/make-zip.xml
index 1dcedc4b16fc4bf016d54fc12983b64877ff949c..f478e57ae68884649b0314532e0f6e727e62fc2b 100644
--- a/src/main/assembly/make-zip.xml
+++ b/src/main/assembly/make-zip.xml
@@ -14,9 +14,9 @@
 			<destName>${project.artifactId}.jar</destName>
 		</file>
 		<file>
-			<source>${project.build.directory}/mexplorer-${mexplorer.version}.jar</source>
+			<source>${project.build.directory}/mexplorer.jar</source>
 			<outputDirectory>${project.artifactId}-${project.version}/lib</outputDirectory>
-			<destName>mexplorer-${mexplorer.version}.jar</destName>
+			<destName>mexplorer.jar</destName>
 		</file>
 	</files>
 	<fileSets>
diff --git a/src/main/config/samples/unimelb-mf-clients-properties.xml b/src/main/config/samples/unimelb-mf-clients-properties.xml
index d6214f8f472b2f23fb76c6f3815bbb2c190ed2d7..d0f23f9ab9a680a6713daedd06f83abf2cae8a02 100644
--- a/src/main/config/samples/unimelb-mf-clients-properties.xml
+++ b/src/main/config/samples/unimelb-mf-clients-properties.xml
@@ -6,7 +6,7 @@
 		<!-- Mediaflux server port -->
 		<port>443</port>
 		<!-- Mediaflux server transport. https, http or tcp/ip -->
-		<protocol>https</protocol>
+		<transport>https</transport>
 		<session>
 			<!-- Retry times on Mediaflux connection failure -->
 			<connectRetryTimes>1</connectRetryTimes>
diff --git a/src/main/config/samples/unimelb-mf-perf-properties.xml b/src/main/config/samples/unimelb-mf-perf-properties.xml
new file mode 100644
index 0000000000000000000000000000000000000000..af715ab7dd14513b507f84c90be8243aad7f33bd
--- /dev/null
+++ b/src/main/config/samples/unimelb-mf-perf-properties.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0"?>
+<properties>
+	<server>
+		<host>localhost</host>
+		<port>8086</port>
+		<transport>http</transport>
+	</server>
+	<credential>
+		<domain>system</domain>
+		<user>manager</user>
+		<password>change_me</password>
+	</credential>
+	<perf>
+		<verbose>true</verbose>
+		<logDirectory>/tmp</logDirectory>
+		<test>
+			<action>ping</action>
+			<action>upload</action>
+			<action>download</action>
+			<numberOfThreads>1</numberOfThreads>
+			<useClusterIO>true</useClusterIO>
+			<useInMemoryFile>false</useInMemoryFile>
+			<numberOfFiles>10</numberOfFiles>
+			<fileSize>10000000</fileSize>
+			<namespace>/mf-perf-test</namespace>
+			<directory>/tmp/mf-perf-test</directory>
+		</test>
+		<result>
+			<asset>
+				<path>/test-result/mf-perf-result.csv</path>
+			</asset>
+			<file>
+				<path>/tmp/mf-perf-result.csv</path>
+			</file>
+		</result>
+	</perf>
+</properties>
diff --git a/src/main/java/unimelb/mf/client/gui/MFSessionGUI.java b/src/main/java/unimelb/mf/client/gui/MFSessionGUI.java
index a93123a3cb5f3042b605e926f71351cc62c4bea0..5f7be830168b29aadd020eba3cab36bb5260ec63 100644
--- a/src/main/java/unimelb/mf/client/gui/MFSessionGUI.java
+++ b/src/main/java/unimelb/mf/client/gui/MFSessionGUI.java
@@ -1,6 +1,7 @@
 package unimelb.mf.client.gui;
 
-import unimelb.mf.client.session.MFConnectionSettings;
+import arc.mf.client.AuthenticationDetails;
+import arc.mf.client.RemoteServer;
 import unimelb.mf.client.session.MFSession;
 
 public class MFSessionGUI extends MFSession {
@@ -8,12 +9,8 @@ public class MFSessionGUI extends MFSession {
     private ErrorDialog _ed;
     private LogonDialog _ld;
 
-    public MFSessionGUI(MFConnectionSettings settings, LogonDialog ld, ErrorDialog ed) {
-        super(settings);
-//        this.settings.setExecuteRetryTimes(0);
-//        this.settings.setExecuteRetryInterval(0);
-//        this.settings.setConnectRetryTimes(0);
-//        this.settings.setConnectRetryInterval(0);
+    protected MFSessionGUI(RemoteServer rs, AuthenticationDetails ad, String session, LogonDialog ld, ErrorDialog ed) {
+        super(rs, ad, session, 0, 0, 0, 0);
         _ld = ld;
         _ed = ed;
     }
diff --git a/src/main/java/unimelb/mf/client/perf/Action.java b/src/main/java/unimelb/mf/client/perf/Action.java
new file mode 100644
index 0000000000000000000000000000000000000000..419bcc55bfe066e5a2ff10ce0c9ae31fd555edad
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/perf/Action.java
@@ -0,0 +1,26 @@
+package unimelb.mf.client.perf;
+
+public enum Action {
+
+    PING("server.ping"), UPLOAD("asset.create"), DOWNLOAD("asset.get");
+
+    private String _service;
+
+    Action(String service) {
+        _service = service;
+    }
+
+    public String service() {
+        return _service;
+    }
+
+    public static Action fromString(String a) {
+        Action[] vs = values();
+        for (Action v : vs) {
+            if (v.name().equalsIgnoreCase(a == null ? null : a.trim())) {
+                return v;
+            }
+        }
+        return null;
+    }
+}
diff --git a/src/main/java/unimelb/mf/client/perf/MFPerf.java b/src/main/java/unimelb/mf/client/perf/MFPerf.java
new file mode 100644
index 0000000000000000000000000000000000000000..c3bff7a444b9d2cb9677873dcfa7101302e55162
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/perf/MFPerf.java
@@ -0,0 +1,142 @@
+package unimelb.mf.client.perf;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.Writer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import arc.mf.client.ServerClient;
+import arc.xml.XmlStringWriter;
+import unimelb.mf.client.session.MFSession;
+import unimelb.mf.client.util.AssetUtils;
+import unimelb.mf.client.util.LoggingUtils;
+
+public class MFPerf implements Callable<Void> {
+
+    public static final String APPLICATION_NAME = "unimelb-mf-perf";
+
+    private MFSession _session;
+    private MFPerfSettings _settings;
+    private Logger _logger;
+
+    public MFPerf(MFSession session, MFPerfSettings settings) throws Throwable {
+        _session = session;
+        _settings = settings;
+        if (_settings.logDirectory() != null) {
+            _logger = LoggingUtils.createFileAndConsoleLogger(_settings.logDirectory(), APPLICATION_NAME);
+        } else {
+            _logger = LoggingUtils.createConsoleLogger(APPLICATION_NAME);
+        }
+    }
+
+    @Override
+    public Void call() throws Exception {
+        try {
+            List<TestResult> results = new ArrayList<TestResult>();
+            List<TestSettings> tss = _settings.testSettings();
+            if (_settings.verbose()) {
+                _logger.info("starting tests...");
+            }
+            for (TestSettings ts : tss) {
+                results.addAll(new Test(_session, ts, _logger, _settings.verbose()).call());
+            }
+            if (_settings.resultAssetID() != null) {
+                if (_settings.verbose()) {
+                    _logger.info("updating result asset " + _settings.resultAssetID() + "...");
+                }
+                updateResultAsset(_session, results, _settings.resultAssetID(), _settings.resultAssetRetain());
+            }
+            if (_settings.resultFilePath() != null) {
+                if (_settings.verbose()) {
+                    _logger.info("updating result file " + _settings.resultFilePath() + "...");
+                }
+                saveResultsToFile(results, _settings.resultFilePath().toFile());
+            }
+            
+            printResults(results);
+            
+            return null;
+        } catch (Throwable e) {
+            _logger.log(Level.SEVERE, e.getMessage(), e);
+            if (e instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            if (e instanceof Exception) {
+                throw (Exception) e;
+            } else {
+                throw new Exception(e);
+            }
+        }
+    }
+
+    private static void updateResultAsset(MFSession session, List<TestResult> results, String resultAssetID, int retain)
+            throws Throwable {
+        updateResultAsset(session, results, resultAssetID, retain, null);
+    }
+
+    private static void updateResultAsset(MFSession session, List<TestResult> results, String resultAssetID, int retain,
+            Path tmpDir) throws Throwable {
+        File tmpFile = tmpDir != null ? File.createTempFile(APPLICATION_NAME + "-result-", ".csv", tmpDir.toFile())
+                : File.createTempFile(APPLICATION_NAME + "-result-", ".csv");
+        try {
+            if (AssetUtils.assetExists(session, resultAssetID)) {
+                XmlStringWriter w = new XmlStringWriter();
+                w.add("id", resultAssetID);
+                session.execute("asset.get", w.document(), new ServerClient.FileOutput(tmpFile));
+            }
+
+            saveResultsToFile(results, tmpFile);
+
+            XmlStringWriter w = new XmlStringWriter();
+            w.add("id", resultAssetID);
+            w.add("create", true);
+            session.execute("asset.set", w.document(), new ServerClient.FileInput(tmpFile));
+            if (retain > 0) {
+                pruneAsset(session, resultAssetID, retain);
+            }
+        } finally {
+            Files.deleteIfExists(tmpFile.toPath());
+        }
+    }
+
+    private static void saveResultsToFile(List<TestResult> results, File file) throws Throwable {
+        boolean fileExists = file.exists();
+        try (Writer w = new BufferedWriter(new FileWriter(file, fileExists))) {
+            if (!fileExists) {
+                TestResult.saveCSVHeader(w);
+            }
+            for (TestResult r : results) {
+                r.saveCSV(w);
+            }
+        }
+    }
+
+    private static void pruneAsset(MFSession session, String assetId, int retain) throws Throwable {
+        XmlStringWriter w = new XmlStringWriter();
+        w.add("id", assetId);
+        w.add("retain", retain);
+        session.execute("asset.prune", w.document());
+    }
+
+    private static void printResults(List<TestResult> results) throws IOException {
+        System.out.println();
+        OutputStreamWriter w = new OutputStreamWriter(System.out);
+        TestResult.saveCSVHeader(w);
+        if (results != null && !results.isEmpty()) {
+            for (TestResult r : results) {
+                r.saveCSV(w);
+            }
+            w.flush();
+        }
+        System.out.println();
+    }
+}
diff --git a/src/main/java/unimelb/mf/client/perf/MFPerfSettings.java b/src/main/java/unimelb/mf/client/perf/MFPerfSettings.java
new file mode 100644
index 0000000000000000000000000000000000000000..9120021f97aed93a5fdb3a176f095af55498a60e
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/perf/MFPerfSettings.java
@@ -0,0 +1,163 @@
+package unimelb.mf.client.perf;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.Reader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import arc.xml.XmlDoc;
+import unimelb.mf.client.session.MFSession;
+
+public class MFPerfSettings {
+
+    public static final int RESULT_ASSET_RETAIN = 10;
+
+    private String _resultAssetID;
+    private String _resultAssetPath;
+    private int _resultAssetRetain = RESULT_ASSET_RETAIN;
+    private Path _resultFilePath;
+    private List<TestSettings> _testSettings;
+    private boolean _verbose = false;
+    private Path _logDir = null;
+
+    MFPerfSettings() {
+        _testSettings = new ArrayList<TestSettings>();
+    }
+
+    MFPerfSettings(XmlDoc.Element pe) throws Throwable {
+        this();
+        parse(pe);
+    }
+
+    public Path resultFilePath() {
+        return _resultFilePath;
+    }
+
+    public String resultAssetID() {
+        if (_resultAssetID != null) {
+            return _resultAssetID;
+        } else if (_resultAssetPath != null) {
+            return "path=" + _resultAssetPath;
+        } else {
+            return null;
+        }
+    }
+
+    public String resultAssetPath() {
+        return _resultAssetPath;
+    }
+
+    public int resultAssetRetain() {
+        return _resultAssetRetain;
+    }
+
+    public boolean verbose() {
+        return _verbose;
+    }
+
+    public Path logDirectory() {
+        return _logDir;
+    }
+
+    public MFPerfSettings setResultFilePath(Path path) {
+        _resultFilePath = path;
+        return this;
+    }
+
+    public MFPerfSettings setResultAssetID(String assetID) {
+        _resultAssetID = assetID;
+        return this;
+    }
+
+    public MFPerfSettings setResultAssetPath(String assetPath) {
+        _resultAssetPath = assetPath;
+        return this;
+    }
+
+    public MFPerfSettings setResultAssetRetain(int retain) {
+        _resultAssetRetain = retain;
+        return this;
+    }
+
+    public MFPerfSettings setResultFilePath(String path) {
+        _resultFilePath = Paths.get(path);
+        return this;
+    }
+
+    public MFPerfSettings setVerbose(boolean verbose) {
+        _verbose = verbose;
+        return this;
+    }
+
+    public MFPerfSettings setLogDirectory(Path logDir) {
+        _logDir = logDir;
+        return this;
+    }
+
+    public void addTestSettings(TestSettings ts) {
+        _testSettings.add(ts);
+    }
+
+    public List<TestSettings> testSettings() {
+        return Collections.unmodifiableList(_testSettings);
+    }
+
+    public void parse(XmlDoc.Element e) throws Throwable {
+        _verbose = e.booleanValue("verbose", false);
+        String logDir = e.value("logDirectory");
+        if (logDir != null) {
+
+        }
+        if (e.elementExists("logDirectory")) {
+            _logDir = Paths.get(e.value("logDirectory"));
+            if (!Files.isDirectory(_logDir)) {
+                throw new IllegalArgumentException(
+                        "Log directory: " + _logDir.toString() + " does not exist or it is not a directory.");
+            }
+        }
+        if (e.elementExists("result/asset/id")) {
+            setResultAssetID(e.value("result/asset/id"));
+        }
+        if (e.elementExists("result/asset/path")) {
+            setResultAssetPath(e.value("result/asset/path"));
+        }
+        if (e.elementExists("result/asset/retain")) {
+            setResultAssetRetain(e.intValue("result/asset/retain"));
+        }
+        if (e.elementExists("result/file/path")) {
+            setResultFilePath(Paths.get(e.value("result/file/path")));
+        }
+        List<XmlDoc.Element> tes = e.elements("test");
+        if (tes != null && !tes.isEmpty()) {
+            for (XmlDoc.Element te : tes) {
+                addTestSettings(new TestSettings(te));
+            }
+        }
+    }
+
+    public void validate(MFSession session) throws Throwable {
+
+        for (TestSettings ts : _testSettings) {
+            ts.validate(session);
+        }
+    }
+
+    public static MFPerfSettings parse(File xmlFile) throws Throwable {
+        try (Reader r = new BufferedReader(new FileReader(xmlFile))) {
+            XmlDoc.Element e = new XmlDoc().parse(r);
+            if (!e.elementExists("perf")) {
+                throw new IllegalArgumentException(
+                        "No perf element found from XML file: " + xmlFile.getPath() + ". Invalid configuration.");
+            }
+            XmlDoc.Element pe = e.element("perf");
+            return new MFPerfSettings(pe);
+        }
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/perf/Test.java b/src/main/java/unimelb/mf/client/perf/Test.java
new file mode 100644
index 0000000000000000000000000000000000000000..74f31ba31b62269b97a971312366214921b80d67
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/perf/Test.java
@@ -0,0 +1,409 @@
+package unimelb.mf.client.perf;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Random;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+import arc.mf.client.ServerClient;
+import arc.xml.XmlDoc;
+import arc.xml.XmlDoc.Element;
+import arc.xml.XmlStringWriter;
+import unimelb.mf.client.session.MFSession;
+import unimelb.mf.client.util.AssetNamespaceUtils;
+
+public class Test implements Callable<List<TestResult>> {
+
+    private TestSettings _settings;
+    private MFSession _session;
+    private Logger _logger;
+    private boolean _verbose;
+
+    public Test(MFSession session, TestSettings settings, Logger logger, boolean verbose) {
+        _session = session;
+        _settings = settings;
+        _logger = logger;
+        _verbose = verbose;
+    }
+
+    @Override
+    public List<TestResult> call() throws Exception {
+        try {
+            List<TestResult> results = new ArrayList<TestResult>();
+            ExecutorService executor = Executors.newFixedThreadPool(_settings.numberOfThreads());
+            if (_settings.ping()) {
+                if (_verbose) {
+                    _logger.info("starting ping test...");
+                }
+                TestResult pr = ping(executor);
+                if (_verbose) {
+                    _logger.info("completed ping test. Result:\n" + pr.toString());
+                }
+                results.add(pr);
+            }
+            if (_settings.upload() || _settings.download()) {
+                String store = _settings.useClusterIO() ? null
+                        : AssetNamespaceUtils.getStore(_session, _settings.namespace());
+                List<String> assetIds = new ArrayList<String>();
+                try {
+                    if (_verbose) {
+                        _logger.info("starting upload test...");
+                    }
+                    TestResult ur = upload(executor, assetIds, store);
+                    if (_verbose) {
+                        _logger.info("completed upload test. Result:\n" + ur.toString());
+                    }
+                    results.add(ur);
+                    if (_settings.download()) {
+                        if (_verbose) {
+                            _logger.info("starting download test...");
+                        }
+                        TestResult dr = download(executor, assetIds);
+                        if (_verbose) {
+                            _logger.info("completed download test. Result:\n" + dr.toString());
+                        }
+                        results.add(dr);
+                    }
+                } finally {
+                    if (!assetIds.isEmpty()) {
+                        if (_verbose) {
+                            _logger.info("cleaning up uploaded test assets in namespace: " + _settings.namespace());
+                        }
+                        destroyAssets(_session, assetIds);
+                    }
+                }
+            }
+            executor.shutdown();
+            executor.awaitTermination(Long.MAX_VALUE, TimeUnit.MICROSECONDS);
+            return results;
+        } catch (Throwable e) {
+            if (e instanceof InterruptedException) {
+                Thread.currentThread().interrupt();
+            }
+            if (e instanceof Exception) {
+                throw (Exception) e;
+            } else {
+                throw new Exception(e);
+            }
+        }
+    }
+
+    private TestResult download(ExecutorService executor, List<String> assetIds) throws Throwable {
+        long totalSize = getTotalContentSize(_session, assetIds);
+        List<Future<Void>> futures = new ArrayList<Future<Void>>(assetIds.size());
+        long startTime = System.currentTimeMillis();
+        for (String assetId : assetIds) {
+            Future<Void> future = executor.submit(new Callable<Void>() {
+
+                @Override
+                public Void call() throws Exception {
+                    try {
+                        download(_session, assetId, _settings.useInMemoryFile() ? null : _settings.directory());
+                        return null;
+                    } catch (Throwable e) {
+                        if (e instanceof Exception) {
+                            throw (Exception) e;
+                        } else {
+                            throw new Exception(e);
+                        }
+                    }
+                }
+            });
+            futures.add(future);
+        }
+        for (Future<Void> future : futures) {
+            future.get();
+        }
+        long endTime = System.currentTimeMillis();
+        return new TestResult(Action.DOWNLOAD, totalSize, startTime, endTime, _settings.numberOfThreads(),
+                assetIds.size(), _settings.fileSize(), _settings.useClusterIO(), _settings.useInMemoryFile(),
+                _settings.useInMemoryFile() ? null : _settings.directory(), _settings.namespace());
+
+    }
+
+    public static long getTotalContentSize(MFSession session, List<String> assetIds) throws Throwable {
+        XmlStringWriter w = new XmlStringWriter();
+        w.add("display", "total");
+        for (String assetId : assetIds) {
+            w.add("id", assetId);
+        }
+        return session.execute("asset.content.size", w.document()).longValue("total");
+    }
+
+    public static void download(MFSession session, String assetId, Path dir) throws Throwable {
+        XmlStringWriter w = new XmlStringWriter();
+        w.add("id", assetId);
+        ServerClient.Output output = null;
+        File file = null;
+        try {
+            if (dir == null) {
+                output = new ServerClient.NullOutput();
+            } else {
+                file = File.createTempFile("test", ".tmp", dir.toFile());
+                output = new ServerClient.FileOutput(file);
+            }
+            session.execute(Action.DOWNLOAD.service(), w.document(), output);
+        } finally {
+            if (file != null) {
+                Files.deleteIfExists(file.toPath());
+            }
+        }
+    }
+
+    private TestResult upload(ExecutorService executor, List<String> assetIds, String store) throws Throwable {
+        if (_settings.useInMemoryFile()) {
+            return upload(executor, _settings.fileSize(), _settings.numberOfFiles(), assetIds, store);
+        } else {
+            List<File> files = generateTmpFiles(_settings.directory(), _settings.fileSize(), _settings.numberOfFiles());
+            try {
+                return upload(executor, files, assetIds, store);
+            } finally {
+                for (File file : files) {
+                    Files.delete(file.toPath());
+                }
+            }
+        }
+    }
+
+    private TestResult upload(ExecutorService executor, long fileSize, int numberOfFiles, List<String> assetIds,
+            String store) throws Throwable {
+        long totalSize = fileSize * numberOfFiles;
+        List<Future<String>> futures = new ArrayList<Future<String>>(numberOfFiles);
+        long startTime = System.currentTimeMillis();
+        for (int i = 0; i < numberOfFiles; i++) {
+            final String name = String.format("test%d-%d.tmp", i, System.currentTimeMillis());
+            Future<String> future = executor.submit(new Callable<String>() {
+
+                @Override
+                public String call() throws Exception {
+                    try {
+                        return upload(_session, fileSize, name, _settings.namespace(), store);
+                    } catch (Throwable e) {
+                        if (e instanceof Exception) {
+                            throw (Exception) e;
+                        } else {
+                            throw new Exception(e);
+                        }
+                    }
+                }
+            });
+            futures.add(future);
+        }
+        for (Future<String> future : futures) {
+            assetIds.add(future.get());
+        }
+        long endTime = System.currentTimeMillis();
+        return new TestResult(Action.UPLOAD, totalSize, startTime, endTime, _settings.numberOfThreads(), numberOfFiles,
+                fileSize, _settings.useClusterIO(), true, null, _settings.namespace());
+
+    }
+
+    private TestResult upload(ExecutorService executor, Collection<File> files, List<String> assetIds, String store)
+            throws Throwable {
+        long totalSize = 0L;
+        for (File file : files) {
+            totalSize += file.length();
+        }
+        List<Future<String>> futures = new ArrayList<Future<String>>(files.size());
+        long startTime = System.currentTimeMillis();
+        for (File file : files) {
+            Future<String> future = executor.submit(new Callable<String>() {
+
+                @Override
+                public String call() throws Exception {
+                    try {
+                        return upload(_session, file, _settings.namespace(), store);
+                    } catch (Throwable e) {
+                        if (e instanceof Exception) {
+                            throw (Exception) e;
+                        } else {
+                            throw new Exception(e);
+                        }
+                    }
+                }
+
+            });
+            futures.add(future);
+        }
+        for (Future<String> future : futures) {
+            assetIds.add(future.get());
+        }
+        long endTime = System.currentTimeMillis();
+        return new TestResult(Action.UPLOAD, totalSize, startTime, endTime, _settings.numberOfThreads(), files.size(),
+                _settings.fileSize(), _settings.useClusterIO(), false, _settings.directory(), _settings.namespace());
+    }
+
+    public static String upload(MFSession session, File file, String namespace, String store) throws Throwable {
+        XmlStringWriter w = new XmlStringWriter();
+        w.add("namespace", namespace);
+        w.add("name", file.getName());
+        if (store != null) {
+            w.add("store", store);
+        }
+        ServerClient.Input input = new ServerClient.FileInput(file);
+        if (store != null) {
+            input.setStore("asset:" + store);
+        }
+        return session.execute(Action.UPLOAD.service(), w.document(), input).value("id");
+    }
+
+    public static String upload(MFSession session, long fileSize, String name, String namespace, String store)
+            throws Throwable {
+        XmlStringWriter w = new XmlStringWriter();
+        w.add("namespace", namespace);
+        w.add("name", name);
+        if (store != null) {
+            w.add("store", store);
+        }
+        ServerClient.Input input = new ServerClient.NullInput(fileSize);
+        input.setStore(store);
+        return session.execute(Action.UPLOAD.service(), w.document(), input).value("id");
+    }
+
+    private TestResult ping(ExecutorService executor) throws Throwable {
+        if (_settings.useInMemoryFile()) {
+            return ping(executor, _settings.fileSize(), _settings.numberOfFiles());
+        } else {
+            List<File> files = generateTmpFiles(_settings.directory(), _settings.fileSize(), _settings.numberOfFiles());
+            try {
+                return ping(executor, files);
+            } finally {
+                for (File file : files) {
+                    Files.delete(file.toPath());
+                }
+            }
+
+        }
+    }
+
+    private TestResult ping(ExecutorService executor, long fileSize, int numberOfFiles) throws Throwable {
+        long totalSize = fileSize * numberOfFiles;
+        List<Future<XmlDoc.Element>> futures = new ArrayList<Future<XmlDoc.Element>>(numberOfFiles);
+        long startTime = System.currentTimeMillis();
+        for (int i = 0; i < numberOfFiles; i++) {
+            Future<XmlDoc.Element> future = executor.submit(new Callable<XmlDoc.Element>() {
+
+                @Override
+                public Element call() throws Exception {
+                    try {
+                        return ping(_session, fileSize);
+                    } catch (Throwable e) {
+                        if (e instanceof Exception) {
+                            throw (Exception) e;
+                        } else {
+                            throw new Exception(e);
+                        }
+                    }
+                }
+            });
+            futures.add(future);
+        }
+        List<XmlDoc.Element> res = new ArrayList<XmlDoc.Element>(futures.size());
+        for (Future<XmlDoc.Element> future : futures) {
+            res.add(future.get());
+        }
+        long endTime = System.currentTimeMillis();
+        return new TestResult(Action.PING, totalSize, startTime, endTime, _settings.numberOfThreads(), numberOfFiles,
+                fileSize, _settings.useClusterIO(), true, null, null);
+    }
+
+    private TestResult ping(ExecutorService executor, Collection<File> files) throws Throwable {
+        long totalSize = 0L;
+        for (File file : files) {
+            totalSize += file.length();
+        }
+        List<Future<XmlDoc.Element>> futures = new ArrayList<Future<XmlDoc.Element>>(files.size());
+        long startTime = System.currentTimeMillis();
+        for (File file : files) {
+            Future<XmlDoc.Element> future = executor.submit(new Callable<XmlDoc.Element>() {
+
+                @Override
+                public Element call() throws Exception {
+                    try {
+                        return ping(_session, file);
+                    } catch (Throwable e) {
+                        if (e instanceof Exception) {
+                            throw (Exception) e;
+                        } else {
+                            throw new Exception(e);
+                        }
+                    }
+                }
+            });
+            futures.add(future);
+        }
+        List<XmlDoc.Element> res = new ArrayList<XmlDoc.Element>(futures.size());
+        for (Future<XmlDoc.Element> future : futures) {
+            res.add(future.get());
+        }
+        long endTime = System.currentTimeMillis();
+        return new TestResult(Action.PING, totalSize, startTime, endTime, _settings.numberOfThreads(), files.size(),
+                _settings.fileSize(), _settings.useClusterIO(), false, _settings.directory(), null);
+    }
+
+    public static XmlDoc.Element ping(MFSession session, File file) throws Throwable {
+        return session.execute(Action.PING.service(), null, new ServerClient.FileInput(file));
+    }
+
+    public static XmlDoc.Element ping(MFSession session, long length) throws Throwable {
+        return session.execute(Action.PING.service(), null, new ServerClient.NullInput(length));
+    }
+
+    private static List<File> generateTmpFiles(Path dir, long length, int nbFiles) throws Throwable {
+        List<File> testFiles = new ArrayList<File>(nbFiles);
+        for (int i = 0; i < nbFiles; i++) {
+            File file = generateTmpFile(dir, String.format("test%d-%d.tmp", i, System.currentTimeMillis()), length);
+            testFiles.add(file);
+        }
+        return testFiles;
+    }
+
+    private static File generateTmpFile(Path dir, String name, long length) throws Throwable {
+
+        Path path = Paths.get(dir.toString(), name);
+        File file = path.toFile();
+        OutputStream out = new BufferedOutputStream(new FileOutputStream(file));
+        byte[] buffer = new byte[1024];
+        Random random = new Random();
+        try {
+            long remaining = length;
+            while (remaining > 0) {
+                random.nextBytes(buffer);
+                if (remaining >= buffer.length) {
+                    out.write(buffer);
+                    remaining -= buffer.length;
+                } else {
+                    out.write(buffer, 0, (int) remaining);
+                    remaining = 0;
+                }
+            }
+            out.flush();
+            return file;
+        } finally {
+            out.close();
+        }
+
+    }
+
+    private static void destroyAssets(MFSession session, Collection<String> assetIds) throws Throwable {
+        XmlStringWriter w = new XmlStringWriter();
+        for (String assetId : assetIds) {
+            w.add("id", assetId);
+        }
+        session.execute("asset.destroy", w.document());
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/perf/TestResult.java b/src/main/java/unimelb/mf/client/perf/TestResult.java
new file mode 100644
index 0000000000000000000000000000000000000000..6af738bedab01efda57019239a15f6756df24ae9
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/perf/TestResult.java
@@ -0,0 +1,118 @@
+package unimelb.mf.client.perf;
+
+import java.io.IOException;
+import java.io.Writer;
+import java.nio.file.Path;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import arc.utils.DateTime;
+
+public class TestResult {
+
+    public final Action action;
+    public final long size;
+    public final long startTime;
+    public final long endTime;
+
+    public final int numberOfThreads;
+    public final int numberOfFiles;
+    public final long fileSize;
+
+    public final boolean useClusterIO;
+    public final boolean useInMemoryFile;
+    public final Path directory;
+    public final String namespace;
+
+    public TestResult(Action action, long size, long startTime, long endTime, int numberOfThreads, int numberOfFiles,
+            long fileSize, boolean useClusterIO, boolean useInMemoryFile, Path directory, String namespace) {
+        this.action = action;
+        this.size = size;
+        this.startTime = startTime;
+        this.endTime = endTime;
+
+        this.numberOfThreads = numberOfThreads;
+        this.numberOfFiles = numberOfFiles;
+        this.useClusterIO = useClusterIO;
+        this.useInMemoryFile = useInMemoryFile;
+        this.fileSize = fileSize;
+        this.directory = directory;
+        this.namespace = namespace;
+    }
+
+    public final long timeMillisecs() {
+        return this.endTime - this.startTime;
+    }
+
+    public final double timeSeconds() {
+        return (double) timeMillisecs() / 1000.0;
+    }
+
+    public final double rateBPS() {
+        return ((double) this.size) / ((double) timeMillisecs() / 1000.0);
+    }
+
+    public final double rateKPS() {
+        return rateBPS() / 1000.0;
+    }
+
+    public final double rateMPS() {
+        return rateBPS() / 1000000.0;
+    }
+
+    public final double rateGPS() {
+        return rateBPS() / 1000000000.0;
+    }
+
+    public void saveCSV(Writer w) throws IOException {
+        StringBuilder sb = new StringBuilder();
+        sb.append(this.action.name().toLowerCase()).append(",");
+        sb.append(this.numberOfThreads).append(",");
+        sb.append(this.useClusterIO).append(",");
+        sb.append(this.useInMemoryFile).append(",");
+        sb.append(this.fileSize).append(",");
+        sb.append(this.numberOfFiles).append(",");
+        sb.append(formatTime(this.startTime)).append(",");
+        sb.append(formatTime(this.endTime)).append(",");
+        sb.append(String.format("%.3f", this.timeSeconds())).append(",");
+        sb.append(String.format("%.3f", this.rateMPS())).append(",");
+        sb.append("\n");
+        w.write(sb.toString());
+    }
+
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append(String.format("\taction: %s,\n", this.action.name().toLowerCase()));
+        sb.append(String.format("\tnumber-of-threads: %s,\n", this.numberOfThreads));
+        sb.append(String.format("\tuse-cluster-io: %s\n", this.useClusterIO));
+        sb.append(String.format("\tuse-in-memory-file: %s\n", this.useInMemoryFile));
+        sb.append(String.format("\tfile-size: %d bytes\n", this.fileSize));
+        sb.append(String.format("\tnumber-of-files: %d\n", this.numberOfFiles));
+        sb.append(String.format("\tstart-time: %s\n", formatTime(this.startTime)));
+        sb.append(String.format("\tend-time: %s\n", formatTime(this.endTime)));
+        sb.append(String.format("\tduration(seconds): %.3f\n", this.timeSeconds()));
+        sb.append(String.format("\trate(MB/s): %.3f\n", this.rateMPS()));
+        return sb.toString();
+    }
+
+    public static void saveCSVHeader(Writer w) throws IOException {
+        StringBuilder sb = new StringBuilder();
+        sb.append("\"Action\"").append(",");
+        sb.append("\"Number of Threads\"").append(",");
+        sb.append("\"Use Cluster IO?\"").append(",");
+        sb.append("\"Use In-Memory File?\"").append(",");
+        sb.append("\"File Size\"").append(",");
+        sb.append("\"Number of Files\"").append(",");
+        sb.append("\"Start Time\"").append(",");
+        sb.append("\"End Time\"").append(",");
+        sb.append("\"Duration(Seconds)\"").append(",");
+        sb.append("\"Rate(MB/s)\"").append(",");
+        sb.append("\n");
+        w.write(sb.toString());
+    }
+
+    static String formatTime(long timeMillisecs) {
+        return new SimpleDateFormat(DateTime.DATE_TIME_MS_FORMAT).format(new Date(timeMillisecs));
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/perf/TestSettings.java b/src/main/java/unimelb/mf/client/perf/TestSettings.java
new file mode 100644
index 0000000000000000000000000000000000000000..7f2c02cb781bc30418b20dba835925f48143b5ac
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/perf/TestSettings.java
@@ -0,0 +1,182 @@
+package unimelb.mf.client.perf;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import arc.xml.XmlDoc;
+import unimelb.mf.client.session.MFSession;
+
+public class TestSettings {
+
+    public static final int DEFAULT_NUMBER_OF_THREADS = 1;
+    public static final int DEFAULT_NUMBER_OF_FILES = 1;
+    public static final long DEFAULT_FILE_SIZE = 1048576L; // 1 MiB;
+
+    private Set<Action> _actions;
+    private int _nbThreads;
+    private int _nbFiles;
+    private boolean _useClusterIO;
+    private boolean _useInMemoryFile;
+    private long _fileSize;
+    private Path _dir;
+    private String _namespace;
+
+    public TestSettings() {
+        _actions = new LinkedHashSet<Action>();
+        _nbThreads = DEFAULT_NUMBER_OF_THREADS;
+        _nbFiles = DEFAULT_NUMBER_OF_FILES;
+        _useClusterIO = false;
+        _useInMemoryFile = true;
+        _fileSize = DEFAULT_FILE_SIZE;
+        _dir = null;
+        _namespace = null;
+
+    }
+
+    public TestSettings(XmlDoc.Element e) throws Throwable {
+        this();
+        parse(e);
+    }
+
+    public boolean ping() {
+        return _actions.contains(Action.PING);
+    }
+
+    public boolean upload() {
+        return _actions.contains(Action.UPLOAD);
+    }
+
+    public boolean download() {
+        return _actions.contains(Action.DOWNLOAD);
+    }
+
+    public int numberOfThreads() {
+        return _nbThreads;
+    }
+
+    public int numberOfFiles() {
+        return _nbFiles;
+    }
+
+    public boolean useClusterIO() {
+        return _useClusterIO;
+    }
+
+    public boolean useInMemoryFile() {
+        return _useInMemoryFile;
+    }
+
+    public long fileSize() {
+        return _fileSize;
+    }
+
+    public Path directory() {
+        return _dir;
+    }
+
+    public String namespace() {
+        return _namespace;
+    }
+
+    public TestSettings addAction(Action action) {
+        _actions.add(action);
+        return this;
+    }
+
+    public TestSettings setNumberOfThreads(int nbThreads) {
+        _nbThreads = nbThreads;
+        return this;
+    }
+
+    public TestSettings setNumberOfFiles(int nbFiles) {
+        _nbFiles = nbFiles;
+        return this;
+    }
+
+    public TestSettings setUseClusterIO(boolean useClusterIO) {
+        _useClusterIO = useClusterIO;
+        return this;
+    }
+
+    public TestSettings setUseInMemoryFile(boolean useInMemoryFile) {
+        _useInMemoryFile = useInMemoryFile;
+        return this;
+    }
+
+    public TestSettings setFileSize(long fileSize) {
+        _fileSize = fileSize;
+        return this;
+    }
+
+    public TestSettings setDirectory(Path dir) {
+        _dir = dir;
+        return this;
+    }
+
+    public TestSettings setNamespace(String ns) {
+        _namespace = ns;
+        return this;
+    }
+
+    public TestSettings parse(XmlDoc.Element e) throws Throwable {
+        if (e != null) {
+            if (e.elementExists("action")) {
+                Collection<String> as = e.values("action");
+                for (String a : as) {
+                    Action action = Action.fromString(a);
+                    if (a == null) {
+                        throw new IllegalArgumentException("Invalid action: " + a);
+                    }
+                    addAction(action);
+                }
+            }
+            if (e.elementExists("threads")) {
+                setNumberOfFiles(e.intValue("threads", 1));
+            }
+            if (e.elementExists("numberOfFiles")) {
+                setNumberOfFiles(e.intValue("numberOfFiles"));
+            }
+            if (e.elementExists("useClusterIO")) {
+                setUseClusterIO(e.booleanValue("useClusterIO"));
+            }
+            if (e.elementExists("useInMemoryFile")) {
+                setUseInMemoryFile(e.booleanValue("useInMemoryFile"));
+            }
+            if (e.elementExists("fileSize")) {
+                setFileSize(e.longValue("fileSize"));
+            }
+            if (e.elementExists("directory")) {
+                Path dir = Paths.get(e.value("directory"));
+                if (!Files.exists(dir)) {
+                    throw new IllegalArgumentException("Directory: " + dir.toString() + " does not exist.");
+                }
+                if (!Files.isDirectory(dir)) {
+                    throw new IllegalArgumentException(dir.toString() + " is not a directory.");
+                }
+                setDirectory(dir);
+            }
+            if (e.elementExists("namespace")) {
+                setNamespace(e.value("namespace"));
+            }
+
+        }
+        return this;
+    }
+
+    public void validate(MFSession session) {
+        if (_actions.isEmpty()) {
+            throw new IllegalArgumentException("Invalid configuration: missing action.");
+        }
+        if (_namespace == null && (_actions.contains(Action.DOWNLOAD) || _actions.contains(Action.UPLOAD))) {
+            throw new IllegalArgumentException("Invalid configuration: missing namespace.");
+        }
+        if (_dir == null && !_useInMemoryFile) {
+            throw new IllegalArgumentException("Invalid configuration: missing directory.");
+        }
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/perf/cli/MFPerfCLI.java b/src/main/java/unimelb/mf/client/perf/cli/MFPerfCLI.java
new file mode 100644
index 0000000000000000000000000000000000000000..dd56543b2de63b0db417840d27e1fe0ed935b330
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/perf/cli/MFPerfCLI.java
@@ -0,0 +1,75 @@
+package unimelb.mf.client.perf.cli;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import unimelb.mf.client.perf.MFPerf;
+import unimelb.mf.client.perf.MFPerfSettings;
+import unimelb.mf.client.session.MFConfigurationBuilder;
+import unimelb.mf.client.session.MFSession;
+
+public class MFPerfCLI {
+
+    public static void main(String[] args) throws Throwable {
+
+        try {
+            Path configFile = null;
+            if (args != null) {
+                for (int i = 0; i < args.length;) {
+                    if ("--config".equalsIgnoreCase(args[i])) {
+                        if (configFile != null) {
+                            throw new IllegalArgumentException(
+                                    "Multiple --config configuration files specified. Expects only one.");
+                        }
+                        configFile = Paths.get(args[i + 1]);
+                        if (!Files.exists(configFile)) {
+                            throw new IllegalArgumentException("Cannot find file: " + args[i + 1]);
+                        }
+                        if (!Files.isRegularFile(configFile)) {
+                            throw new IllegalArgumentException("File: " + args[i + 1] + " is not a regular file.");
+                        }
+                        i += 2;
+                    } else {
+                        throw new IllegalArgumentException("Unexpected argument: " + args[i]);
+                    }
+                }
+            }
+            if (configFile == null) {
+                throw new IllegalArgumentException("No --config configuration file is specified.");
+            }
+
+            MFConfigurationBuilder mfconfig = new MFConfigurationBuilder();
+            mfconfig.loadFromXmlFile(configFile.toFile());
+            mfconfig.setApp(MFPerf.APPLICATION_NAME);
+
+            MFSession session = MFSession.create(mfconfig);
+
+            MFPerfSettings settings = MFPerfSettings.parse(configFile.toFile());
+            if (settings.testSettings().isEmpty()) {
+                throw new IllegalArgumentException(
+                        "Failed to parse settings from configuration file: " + configFile.toString());
+            }
+            settings.validate(session);
+
+            new MFPerf(session, settings).call();
+        } catch (Throwable e) {
+            e.printStackTrace();
+            if (e instanceof IllegalArgumentException) {
+                printUsage();
+            }
+        }
+
+    }
+
+    public static void printUsage() {
+        System.out.println();
+        System.out.println("USAGE:");
+        System.out.println(String.format("    %s --config <config-file>", MFPerf.APPLICATION_NAME));
+        System.out.println();
+        System.out.println("DESCRIPTION:");
+        System.out.println("    Test network transfer rates to/from the specified Mediaflux server.");
+        System.out.println();
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/session/AuthenticationDetailsUtils.java b/src/main/java/unimelb/mf/client/session/AuthenticationDetailsUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..c881e1dbf90863438147f88f545768e9c3edd5fb
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/session/AuthenticationDetailsUtils.java
@@ -0,0 +1,25 @@
+package unimelb.mf.client.session;
+
+import arc.mf.client.AuthenticationDetails;
+import unimelb.mf.client.util.ObjectUtils;
+
+public class AuthenticationDetailsUtils {
+
+    public static boolean equals(AuthenticationDetails ad1, AuthenticationDetails ad2) {
+        if (ad1 == ad2) {
+            return true;
+        }
+        if (ad1 == null || ad2 == null) {
+            return false;
+        }
+        return ObjectUtils.equals(ad1.application(), ad2.application()) && ObjectUtils.equals(ad1.token(), ad2.token())
+                && ObjectUtils.equals(ad1.domain(), ad2.domain()) && ObjectUtils.equals(ad1.userName(), ad2.userName())
+                && ObjectUtils.equals(ad1.userPassword(), ad2.userPassword())
+                && ObjectUtils.equals(ad1.loginIdentity(), ad2.loginIdentity());
+    }
+
+    public static boolean differ(AuthenticationDetails ad1, AuthenticationDetails ad2) {
+        return !equals(ad1, ad2);
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/session/MFConfiguration.java b/src/main/java/unimelb/mf/client/session/MFConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..681e3d6a15e6cb20d090b16914a1cf6c99077906
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/session/MFConfiguration.java
@@ -0,0 +1,82 @@
+package unimelb.mf.client.session;
+
+import java.io.File;
+
+import arc.mf.client.AuthenticationDetails;
+import arc.mf.client.ConnectionDetails;
+
+/**
+ * See src/main/config/mf-sync-properties.sample.xml
+ * 
+ * @author wliu5
+ *
+ */
+public class MFConfiguration {
+
+    public static final String DEFAULT_MFLUX_CFG_FILE = System.getProperty("user.home") + File.separator + ".Arcitecta"
+            + File.separator + "mflux.cfg";
+    public static final String ENV_MFLUX_CFG = "MFLUX_CFG";
+    public static final String PROPERTY_MF_CONFIG = "mf.cfg";
+
+    private ConnectionDetails _connectionDetails;
+    private AuthenticationDetails _authenticationDetails;
+
+    private boolean _connectionPooling = true;
+    private int _connectRetryTimes = MFSession.DEFAULT_CONNECT_RETRY_TIMES;
+    private int _connectRetryInterval = MFSession.DEFAULT_CONNECT_RETRY_INTERVAL;
+    private int _executeRetryTimes = MFSession.DEFAULT_EXECUTE_RETRY_TIMES;
+    private int _executeRetryInterval = MFSession.DEFAULT_EXECUTE_RETRY_INTERVAL;
+    private String _sessionKey = null;
+
+    public MFConfiguration(ConnectionDetails connectionDetails, AuthenticationDetails authenticationDetails,
+            boolean connectionPooling, int connectRetryTimes, int connectRetryInterval, int execRetryTimes,
+            int execRetryInterval, String sessionKey) {
+        _connectionDetails = connectionDetails;
+        _authenticationDetails = authenticationDetails;
+        _connectionPooling = connectionPooling;
+        _connectRetryTimes = connectRetryTimes;
+        _connectRetryInterval = connectRetryInterval;
+        _executeRetryTimes = execRetryTimes;
+        _executeRetryInterval = execRetryInterval;
+        _sessionKey = sessionKey;
+    }
+
+    public MFConfiguration(ConnectionDetails connectionDetails, AuthenticationDetails authenticationDetails) {
+        this(connectionDetails, authenticationDetails, true, MFSession.DEFAULT_CONNECT_RETRY_TIMES,
+                MFSession.DEFAULT_CONNECT_RETRY_INTERVAL, MFSession.DEFAULT_EXECUTE_RETRY_TIMES,
+                MFSession.DEFAULT_EXECUTE_RETRY_INTERVAL, null);
+    }
+
+    public ConnectionDetails connectionDetails() {
+        return _connectionDetails;
+    }
+
+    public AuthenticationDetails authenticationDetails() {
+        return _authenticationDetails;
+    }
+
+    public String sessionKey() {
+        return _sessionKey;
+    }
+
+    public int executeRetryInterval() {
+        return _executeRetryInterval;
+    }
+
+    public int connectRetryInterval() {
+        return _connectRetryInterval;
+    }
+
+    public int executeRetryTimes() {
+        return _executeRetryTimes;
+    }
+
+    public int connectRetryTimes() {
+        return _connectRetryTimes;
+    }
+
+    public boolean connectionPooling() {
+        return _connectionPooling;
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/session/MFConnectionSettings.java b/src/main/java/unimelb/mf/client/session/MFConfigurationBuilder.java
similarity index 62%
rename from src/main/java/unimelb/mf/client/session/MFConnectionSettings.java
rename to src/main/java/unimelb/mf/client/session/MFConfigurationBuilder.java
index d5b9380a013c11cc4203a7ae89d48046c1c066c3..d4c260a96095b2b71186a42828d86efb20892922 100644
--- a/src/main/java/unimelb/mf/client/session/MFConnectionSettings.java
+++ b/src/main/java/unimelb/mf/client/session/MFConfigurationBuilder.java
@@ -14,72 +14,60 @@ import java.util.List;
 import java.util.Properties;
 
 import arc.mf.client.AuthenticationDetails;
+import arc.mf.client.ConnectionDetails;
+import arc.mf.client.ConnectionSpec;
 import arc.xml.XmlDoc;
 
-/**
- * See src/main/config/mf-sync-properties.sample.xml
- * 
- * @author wliu5
- *
- */
-public class MFConnectionSettings {
-
-    public static final String DEFAULT_MFLUX_CFG_FILE = System.getProperty("user.home") + File.separator + ".Arcitecta"
-            + File.separator + "mflux.cfg";
-    public static final String ENV_MFLUX_CFG = "MFLUX_CFG";
-    public static final String PROPERTY_MF_CONFIG = "mf.config";
-
-    private String _serverHost = null;
-    private String _serverTransport = null;
-    private int _serverPort;
-    private boolean _connectionPooling = true;
+public class MFConfigurationBuilder {
 
-    private int _connectRetryTimes = MFSession.DEFAULT_CONNECT_RETRY_TIMES;
+    private boolean _allowUntrustedServer = true;
+    private String _app = null;
+    private boolean _connectionPooling = true;
     private int _connectRetryInterval = MFSession.DEFAULT_CONNECT_RETRY_INTERVAL;
-    private int _executeRetryTimes = MFSession.DEFAULT_EXECUTE_RETRY_TIMES;
-    private int _executeRetryInterval = MFSession.DEFAULT_EXECUTE_RETRY_INTERVAL;
+    private int _connectRetryTimes = MFSession.DEFAULT_CONNECT_RETRY_TIMES;
+
+    private boolean _consoleLogon = true;
 
-    private String _app = null;
     private String _domain = null;
-    private String _user = null;
+    private boolean _encrypt = true;
+    private int _executeRetryInterval = MFSession.DEFAULT_EXECUTE_RETRY_INTERVAL;
+    private int _executeRetryTimes = MFSession.DEFAULT_EXECUTE_RETRY_TIMES;
+
+    private String _host = null;
     private String _password = null;
-    private String _token = null;
+    private int _port;
     private String _sessionKey = null;
+    private String _token = null;
+    private boolean _useHttp = true;
 
-    public MFConnectionSettings() throws Throwable {
+    private String _user = null;
 
-    }
+    public MFConfigurationBuilder() throws Throwable {
 
-    public MFConnectionSettings(Path xmlFile) throws Throwable {
-        this(xmlFile == null ? null : xmlFile.toFile());
     }
 
-    public MFConnectionSettings(File xmlFile) throws Throwable {
+    public MFConfigurationBuilder(File xmlFile) throws Throwable {
         if (xmlFile != null && xmlFile.exists()) {
             loadFromXmlFile(xmlFile);
         }
     }
 
-    public MFConnectionSettings(XmlDoc.Element pe) throws Throwable {
+    public MFConfigurationBuilder(Path xmlFile) throws Throwable {
+        this(xmlFile == null ? null : xmlFile.toFile());
+    }
+
+    public MFConfigurationBuilder(XmlDoc.Element pe) throws Throwable {
         if (pe != null) {
             loadFromXml(pe);
         }
     }
 
-    private static String parseServerTransport(String proto) throws Exception {
-        if (proto != null) {
-            if ("http".equalsIgnoreCase(proto)) {
-                return "http";
-            }
-            if ("https".equalsIgnoreCase(proto)) {
-                return "https";
-            }
-            if (proto.toLowerCase().startsWith("tcp")) {
-                return "tcp/ip";
-            }
-            throw new Exception("Invalid transport protocol: " + proto);
-        }
-        return null;
+    public boolean allowUntrustedServer() {
+        return _allowUntrustedServer;
+    }
+
+    public String app() {
+        return _app;
     }
 
     public AuthenticationDetails authenticationDetails() {
@@ -94,18 +82,139 @@ public class MFConnectionSettings {
         }
     }
 
+    public MFConfiguration build() {
+        checkMissingArguments();
+        return new MFConfiguration(connectionDetails(), authenticationDetails(), connectionPooling(),
+                connectRetryTimes(), connectRetryInterval(), executeRetryTimes(), executeRetryInterval(), sessionKey());
+    }
+
+    public void checkMissingArguments() throws IllegalArgumentException {
+        if (_host == null) {
+            throw new IllegalArgumentException("Missing mf.host");
+        }
+        if (_port <= 0) {
+            throw new IllegalArgumentException("Missing mf.port");
+        }
+        if (transport() == null) {
+            throw new IllegalArgumentException("Missing mf.transport");
+        }
+        if (_token == null && (_domain == null || _user == null || _password == null) && _sessionKey == null) {
+            throw new IllegalArgumentException("Missing/Incomplete mf.token or mf.auth.");
+        }
+    }
+
+    public ConnectionDetails connectionDetails() {
+        if (hasConnectionDetails()) {
+            return new ConnectionSpec().setHostName(_host).setPort(_port).setUseHttp(_useHttp).setEncrypt(_encrypt)
+                    .setAllowUntrustedServer(_allowUntrustedServer).build();
+        } else {
+            return null;
+        }
+    }
+
+    public boolean connectionPooling() {
+        return _connectionPooling;
+    }
+
+    public int connectRetryInterval() {
+        return _connectRetryInterval;
+    }
+
+    public int connectRetryTimes() {
+        return _connectRetryTimes;
+    }
+
+    public boolean consoleLogon() {
+        return _consoleLogon;
+    }
+
     public String domain() {
         return _domain;
     }
 
     public boolean encrypt() {
-        return "https".equalsIgnoreCase(_serverTransport);
+        return _encrypt;
+    }
+
+    public int executeRetryInterval() {
+        return _executeRetryInterval;
+    }
+
+    public int executeRetryTimes() {
+        return _executeRetryTimes;
+    }
+
+    // @formatter:off
+    /**
+     * Try finding mflux.cfg file in the following order:
+     *     1. try system property: mf.cfg
+     *     2. try system environment variable: MFLUX_CFG
+     *     3. try default location: $HOME/.Arcitecta/mflux.cfg
+     * @return the absolute path of the configuration file. if not found, return null.
+     * @throws Throwable
+     */
+    // @formatter:on
+    public String findAndLoadFromConfigFile() throws Throwable {
+        String cfgFile = System.getProperty(MFConfiguration.PROPERTY_MF_CONFIG);
+
+        /*
+         * try system property: mf.cfg
+         */
+        if (cfgFile != null) {
+            File f = new File(cfgFile);
+            if (f.exists()) {
+                loadFromConfigFile(f);
+                return f.getAbsolutePath();
+            }
+        }
+
+        /*
+         * try system environment variable: MFLUX_CFG
+         */
+        cfgFile = System.getenv(MFConfiguration.ENV_MFLUX_CFG);
+        if (cfgFile != null) {
+            File f = new File(cfgFile);
+            if (f.exists()) {
+                loadFromConfigFile(f);
+                return f.getAbsolutePath();
+            }
+        }
+
+        /*
+         * try default location: $HOME/.Arcitecta/mflux.cfg
+         */
+        cfgFile = MFConfiguration.DEFAULT_MFLUX_CFG_FILE;
+        if (cfgFile != null) {
+            File f = new File(cfgFile);
+            if (f.exists()) {
+                loadFromConfigFile(f);
+                return f.getAbsolutePath();
+            }
+        }
+        return null;
     }
 
     public boolean hasAuthenticationDetails() {
         return hasToken() || hasUserCredentials();
     }
 
+    public boolean hasConnectionDetails() {
+        return _host != null && _port > 0;
+    }
+
+    public boolean hasMissingArgument() {
+        if (_host == null) {
+            return true;
+        }
+        if (_port <= 0) {
+            return true;
+        }
+        if (_token == null && (_domain == null || _user == null || _password == null) && _sessionKey == null) {
+            return true;
+        }
+        return false;
+    }
+
     public boolean hasSessionKey() {
         return _sessionKey != null;
     }
@@ -118,23 +227,144 @@ public class MFConnectionSettings {
         return _domain != null && _user != null && _password != null;
     }
 
+    public String host() {
+        return _host;
+    }
+
+    public void loadFromConfigFile(File configFile) throws Exception {
+        Properties props = new Properties();
+        InputStream in = new BufferedInputStream(new FileInputStream(configFile));
+        try {
+            props.load(in);
+            if (props.containsKey("host")) {
+                String host = props.getProperty("host");
+                if (host != null) {
+                    host = host.trim();
+                    if (!host.isEmpty()) {
+                        setHost(host);
+                    }
+                }
+            }
+            if (props.containsKey("port")) {
+                String portStr = props.getProperty("port");
+                if (portStr != null) {
+                    portStr = portStr.trim();
+                    if (!portStr.isEmpty()) {
+                        int port = Integer.parseInt(portStr);
+                        setPort(port);
+                    }
+                }
+            }
+            if (props.containsKey("transport")) {
+                String transport = props.getProperty("transport");
+                if (transport != null) {
+                    transport = transport.trim();
+                    if (!transport.isEmpty()) {
+                        setTransport(transport);
+                    }
+                }
+            }
+            if (props.containsKey("domain")) {
+                String domain = props.getProperty("domain");
+                if (domain != null) {
+                    domain = domain.trim();
+                    if (!domain.isEmpty()) {
+                        setDomain(domain);
+                    }
+                }
+            }
+            if (props.containsKey("user")) {
+                String user = props.getProperty("user");
+                if (user != null) {
+                    user = user.trim();
+                    if (!user.isEmpty()) {
+                        setUser(user);
+                    }
+                }
+            }
+            if (props.containsKey("password")) {
+                String password = props.getProperty("password");
+                if (password != null) {
+                    password = password.trim();
+                    if (!password.isEmpty()) {
+                        setPassword(password);
+                    }
+                }
+            }
+            if (props.containsKey("token")) {
+                String token = props.getProperty("token");
+                if (token != null) {
+                    token = token.trim();
+                    if (!token.isEmpty()) {
+                        setToken(token);
+                    }
+                }
+            }
+        } finally {
+            in.close();
+        }
+    }
+
+    public void loadFromConfigFile(String configFile) throws Exception {
+        loadFromConfigFile(new File(configFile));
+    }
+
+    /**
+     * Load the specified Mediaflux configuration file. If the specified file is
+     * null or the file is not found. Try finding and loading the file specified
+     * in 1) system property; 2) system environment variable 3) default
+     * location: $HOME/.Arcitecta/mflux.cfg
+     * 
+     * @param configFile
+     * @throws Throwable
+     */
+    public String loadFromConfigFileOrFind(String configFile) throws Throwable {
+        if (configFile != null) {
+            File cfgFile = new File(configFile);
+            if (cfgFile.exists()) {
+                loadFromConfigFile(cfgFile);
+                return cfgFile.getAbsolutePath();
+            }
+        }
+        return findAndLoadFromConfigFile();
+    }
+
     private void loadFromXml(XmlDoc.Element pe) throws Throwable {
 
         // @formatter:off
-        _serverHost = pe.value("server/host");
-        _serverTransport = parseServerTransport(pe.value("server/protocol"));
-        _serverPort = pe.intValue("server/port", 0);
-        if (_serverPort == 0) {
-            if ("http".equalsIgnoreCase(_serverTransport)) {
-                _serverPort = 80;
+        _host = pe.value("server/host");
+        _port = pe.intValue("server/port", 0);
+
+        String transport = "HTTPS";
+        if(pe.elementExists("server/transport")) {
+            transport = pe.stringValue("server/transport", "HTTPS");
+        } else {
+            // back compatible with old element name: protocol
+            transport = pe.stringValue("server/protocol", "HTTPS");
+        }
+        
+        if ("HTTP".equalsIgnoreCase(transport)) {
+            _useHttp = true;
+            _encrypt = false;
+            if (_port == 0) {
+            _port = 80;
             }
-            if ("https".equalsIgnoreCase(_serverTransport)) {
-                _serverPort = 443;
+        }
+        if ("HTTPS".equalsIgnoreCase(transport)) {
+            _useHttp = true;
+            _encrypt = true;
+            if (_port == 0) {
+            _port = 443;
             }
-            if ("tcp/ip".equalsIgnoreCase(_serverTransport)) {
-                _serverPort = 1967;
+        }
+        if (transport.toLowerCase().startsWith("tcp")) {
+            _useHttp = false;
+            _encrypt = false;
+            if (_port == 0) {
+            _port = 1967;
             }
         }
+        
         _connectRetryTimes = pe.intValue("server/session/connectRetryTimes", MFSession.DEFAULT_CONNECT_RETRY_TIMES);
         _connectRetryInterval = pe.intValue("server/session/connectRetryInterval", MFSession.DEFAULT_CONNECT_RETRY_INTERVAL);
         _executeRetryTimes = pe.intValue("server/session/executeRetryTimes", MFSession.DEFAULT_EXECUTE_RETRY_TIMES);
@@ -160,40 +390,85 @@ public class MFConnectionSettings {
         }
     }
 
-    public String password() {
-        return _password;
-    }
-
-    public String serverHost() {
-        return _serverHost;
-    }
-
-    public int serverPort() {
-        return _serverPort;
-    }
-
-    public String serverTransport() {
-        return _serverTransport;
-    }
-
-    public String sessionKey() {
-        return _sessionKey;
-    }
-
-    public MFConnectionSettings setApp(String app) {
-        _app = app;
-        return this;
+    public List<String> parseArgs(String[] args) throws Throwable {
+        return parseArgs(args, 0, args.length);
     }
 
-    public MFConnectionSettings setDomain(String domain) {
-        _domain = domain;
-        return this;
-    }
+    public List<String> parseArgs(String[] args, int offset, int length) throws Throwable {
 
-    public MFConnectionSettings readDomainFromConsole(Console console) {
-        String domain = null;
-        do {
-            domain = _domain == null ? console.readLine("Domain: ") : console.readLine("Domain[%s]: ", _domain);
+        List<String> remainArgs = new ArrayList<String>();
+        for (int i = offset; i < offset + length;) {
+            if ("--mf.config".equalsIgnoreCase(args[i])) {
+                try {
+                    loadFromConfigFile(args[i + 1]);
+                } catch (Throwable e) {
+                    throw new IllegalArgumentException("Invalid --mf.config: " + args[i + 1], e);
+                }
+                i += 2;
+            } else if ("--mf.host".equalsIgnoreCase(args[i])) {
+                setHost(args[i + 1]);
+                i += 2;
+            } else if ("--mf.port".equalsIgnoreCase(args[i])) {
+                setPort(Integer.parseInt(args[i + 1]));
+                i += 2;
+            } else if ("--mf.transport".equalsIgnoreCase(args[i])) {
+                setTransport(args[i + 1]);
+                i += 2;
+            } else if ("--mf.auth".equalsIgnoreCase(args[i])) {
+                String auth = args[i + 1];
+                String[] parts = auth.split(",");
+                if (parts == null || parts.length != 3) {
+                    throw new IllegalArgumentException("Invalid mf.auth: " + auth);
+                }
+                setUserCredentials(parts[0], parts[1], parts[2]);
+                i += 2;
+            } else if ("--mf.token".equalsIgnoreCase(args[i])) {
+                setToken(args[i + 1]);
+                i += 2;
+            } else {
+                remainArgs.add(args[i]);
+                i++;
+            }
+        }
+
+        return remainArgs;
+    }
+
+    private void parseTransport(String proto) {
+        if (proto != null) {
+            proto = proto.trim();
+            if (!proto.isEmpty()) {
+                if ("HTTP".equalsIgnoreCase(proto)) {
+                    _useHttp = true;
+                    _encrypt = false;
+                    return;
+                } else if ("HTTPS".equalsIgnoreCase(proto)) {
+                    _useHttp = true;
+                    _encrypt = true;
+                    return;
+                } else if (proto.toLowerCase().startsWith("tcp")) {
+                    _useHttp = false;
+                    _encrypt = false;
+                    return;
+                } else {
+                    throw new IllegalArgumentException("Invalid transport protocol: " + proto);
+                }
+            }
+        }
+    }
+
+    public String password() {
+        return _password;
+    }
+
+    public int port() {
+        return _port;
+    }
+
+    public MFConfigurationBuilder readDomainFromConsole(Console console) {
+        String domain = null;
+        do {
+            domain = _domain == null ? console.readLine("Domain: ") : console.readLine("Domain[%s]: ", _domain);
             if (_domain != null && domain != null && domain.trim().isEmpty()) {
                 // use existing value, no change
                 return this;
@@ -204,12 +479,7 @@ public class MFConnectionSettings {
         return this;
     }
 
-    public MFConnectionSettings setPassword(String password) {
-        _password = password;
-        return this;
-    }
-
-    public MFConnectionSettings readPasswordFromConsole(Console console) {
+    public MFConfigurationBuilder readPasswordFromConsole(Console console) {
         String password = null;
         do {
             char[] pwd = console.readPassword("Password: ");
@@ -222,53 +492,25 @@ public class MFConnectionSettings {
         return this;
     }
 
-    public MFConnectionSettings setServer(String host, int port, boolean useHttp, boolean encrypt) {
-        _serverHost = host;
-        _serverPort = port;
-        if (useHttp) {
-            _serverTransport = encrypt ? "https" : "http";
-        } else {
-            _serverTransport = "tcp/ip";
-        }
-        return this;
-    }
-
-    public MFConnectionSettings setServer(String host, int port, String transport) throws Exception {
-        _serverHost = host;
-        _serverPort = port;
-        setServerTransport(transport);
-        return this;
-    }
-
-    public MFConnectionSettings setServerHost(String host) {
-        _serverHost = host;
-        return this;
-    }
-
-    public MFConnectionSettings readServerHostFromConsole(Console console) {
+    public MFConfigurationBuilder readServerHostFromConsole(Console console) {
         String host = null;
         do {
-            host = _serverHost == null ? console.readLine("Host: ") : console.readLine("Host[%s]: ", _serverHost);
-            if (_serverHost != null && host != null && host.trim().isEmpty()) {
+            host = _host == null ? console.readLine("Host: ") : console.readLine("Host[%s]: ", _host);
+            if (_host != null && host != null && host.trim().isEmpty()) {
                 // use existing value, no change
                 return this;
             }
         } while (host == null || host.trim().isEmpty());
 
-        _serverHost = host.trim();
+        _host = host.trim();
         return this;
     }
 
-    public MFConnectionSettings setServerPort(int port) {
-        _serverPort = port;
-        return this;
-    }
-
-    public MFConnectionSettings readServerPortFromConsole(Console console) {
+    public MFConfigurationBuilder readServerPortFromConsole(Console console) {
         int port = -1;
         do {
-            String p = _serverPort <= 0 ? console.readLine("Port: ") : console.readLine("Port[%d]: ", _serverPort);
-            if (_serverPort > 0 && p != null && p.trim().isEmpty()) {
+            String p = _port <= 0 ? console.readLine("Port: ") : console.readLine("Port[%d]: ", _port);
+            if (_port > 0 && p != null && p.trim().isEmpty()) {
                 // use existing value, no change
                 return this;
             }
@@ -284,57 +526,35 @@ public class MFConnectionSettings {
             }
         } while (port <= 0 || port > 65535);
 
-        _serverPort = port;
-        return this;
-    }
-
-    public MFConnectionSettings setServerTransport(String transport) throws Exception {
-        _serverTransport = parseServerTransport(transport);
+        _port = port;
         return this;
     }
 
-    public MFConnectionSettings readServerTransportFromConsole(Console console) {
-        String transport = null;
-        do {
-            transport = _serverTransport == null ? console.readLine("Transport(https/http/tcpip): ")
-                    : console.readLine("Transport[%s]: ", _serverTransport);
-            if (_serverTransport != null && transport != null && transport.trim().isEmpty()) {
-                // use existing value, no change
-                return this;
-            }
+    public MFConfigurationBuilder readServerTransportFromConsole(Console console) {
+        while (true) {
+            String transport = console.readLine("Transport(HTTP/HTTPS/TCP_IP)[%s]: ", transport());
             if (transport != null) {
-                transport = transport.trim();
-                if (!transport.equalsIgnoreCase("http") && !transport.equalsIgnoreCase("https")
-                        && !transport.toLowerCase().startsWith("tcp")) {
-                    // invalid value
-                    console.printf("%n");
-                    console.printf("Invalid transport: %s. Expects http, https or tcp/ip%n", transport);
-                    console.printf("%n");
-                    transport = null;
+                if (transport.trim().isEmpty()) {
+                    // use existing value, no change
+                    return this;
+                } else {
+                    transport = transport.trim();
+                    if (!transport.equalsIgnoreCase("HTTP") && !transport.equalsIgnoreCase("HTTPS")
+                            && !transport.toLowerCase().startsWith("tcp")) {
+                        // invalid value
+                        console.printf("%n");
+                        console.printf("Invalid transport: %s. Expects HTTP, HTTPS or TCP_IP%n", transport);
+                        console.printf("%n");
+                    } else {
+                        parseTransport(transport);
+                        return this;
+                    }
                 }
             }
-        } while (transport == null || transport.isEmpty());
-
-        _serverTransport = transport;
-        return this;
-    }
-
-    public MFConnectionSettings setSessionKey(String sessionKey) {
-        _sessionKey = sessionKey;
-        return this;
-    }
-
-    public MFConnectionSettings setToken(String token) {
-        _token = token;
-        return this;
-    }
-
-    public MFConnectionSettings setUser(String user) {
-        _user = user;
-        return this;
+        }
     }
 
-    public MFConnectionSettings readUserFromConsole(Console console) {
+    public MFConfigurationBuilder readUserFromConsole(Console console) {
         String user = null;
         do {
             user = _user == null ? console.readLine("User: ") : console.readLine("User[%s]: ", _user);
@@ -348,290 +568,150 @@ public class MFConnectionSettings {
         return this;
     }
 
-    public MFConnectionSettings setUserCredentials(String domain, String user, String password) {
-        _domain = domain;
-        _user = user;
-        _password = password;
-        return this;
+    public String sessionKey() {
+        return _sessionKey;
     }
 
-    public String token() {
-        return _token;
+    public MFConfigurationBuilder setAllowUntrustedServer(boolean allowUntrustedServer) {
+        _allowUntrustedServer = allowUntrustedServer;
+        return this;
     }
 
-    public boolean useHttp() {
-        return "https".equalsIgnoreCase(_serverTransport) || "http".equalsIgnoreCase(_serverTransport);
+    public MFConfigurationBuilder setApp(String app) {
+        _app = app;
+        return this;
     }
 
-    public String user() {
-        return _user;
+    public MFConfigurationBuilder setAuthenticationDetails(AuthenticationDetails authenticationDetails) {
+        _domain = authenticationDetails.domain();
+        _user = authenticationDetails.userName();
+        _password = authenticationDetails.userPassword();
+        _token = authenticationDetails.token();
+        _app = authenticationDetails.application();
+        return this;
     }
 
-    public MFConnectionSettings setConnectRetryTimes(int retryTimes) {
-        _connectRetryTimes = retryTimes;
+    public MFConfigurationBuilder setConnectionDetails(ConnectionDetails connectionDetails) {
+        _host = connectionDetails.hostName();
+        if (_host == null) {
+            _host = connectionDetails.hostAddressOrName();
+        }
+        _port = connectionDetails.port();
+        _useHttp = connectionDetails.useHttp();
+        _encrypt = connectionDetails.encrypt();
+        _allowUntrustedServer = connectionDetails.allowUntrustedServer();
         return this;
     }
 
-    public int connectRetryTimes() {
-        return _connectRetryTimes;
+    public void setConnectionPooling(boolean connectionPooling) {
+        _connectionPooling = connectionPooling;
     }
 
-    public MFConnectionSettings setConnectRetryInterval(int millisecs) {
+    public MFConfigurationBuilder setConnectRetryInterval(int millisecs) {
         _connectRetryInterval = millisecs;
         return this;
     }
 
-    public int connectRetryInterval() {
-        return _connectRetryInterval;
+    public MFConfigurationBuilder setConnectRetryTimes(int retryTimes) {
+        _connectRetryTimes = retryTimes;
+        return this;
     }
 
-    public MFConnectionSettings setExecuteRetryTimes(int retryTimes) {
-        _executeRetryTimes = retryTimes;
+    public MFConfigurationBuilder setConsoleLogon(boolean consoleLogon) {
+        _consoleLogon = consoleLogon;
         return this;
     }
 
-    public int executeRetryTimes() {
-        return _executeRetryTimes;
+    public MFConfigurationBuilder setDomain(String domain) {
+        _domain = domain;
+        return this;
     }
 
-    public MFConnectionSettings setExecuteRetryInterval(int millisecs) {
+    public MFConfigurationBuilder setExecuteRetryInterval(int millisecs) {
         _executeRetryInterval = millisecs;
         return this;
     }
 
-    public int executeRetryInterval() {
-        return _executeRetryInterval;
+    public MFConfigurationBuilder setExecuteRetryTimes(int retryTimes) {
+        _executeRetryTimes = retryTimes;
+        return this;
     }
 
-    public boolean connectionPooling() {
-        return _connectionPooling;
+    public MFConfigurationBuilder setHost(String host) {
+        _host = host;
+        return this;
     }
 
-    public void setConnectionPooling(boolean connectionPooling) {
-        _connectionPooling = connectionPooling;
+    public MFConfigurationBuilder setPassword(String password) {
+        _password = password;
+        return this;
     }
 
-    public void checkMissingArguments() throws Throwable {
-        if (_serverHost == null) {
-            throw new IllegalArgumentException("Missing mf.host");
-        }
-        if (_serverPort <= 0) {
-            throw new IllegalArgumentException("Missing mf.port");
-        }
-        if (serverTransport() == null) {
-            throw new IllegalArgumentException("Missing mf.transport");
-        }
-        if (_token == null && (_domain == null || _user == null || _password == null) && _sessionKey == null) {
-            throw new IllegalArgumentException("Missing/Incomplete mf.token or mf.auth.");
-        }
+    public MFConfigurationBuilder setPort(int port) {
+        _port = port;
+        return this;
     }
 
-    public boolean hasMissingArgument() {
-        if (_serverHost == null) {
-            return true;
-        }
-        if (_serverPort <= 0) {
-            return true;
-        }
-        if (serverTransport() == null) {
-            return true;
-        }
-        if (_token == null && (_domain == null || _user == null || _password == null) && _sessionKey == null) {
-            return true;
-        }
-        return false;
+    public MFConfigurationBuilder setServer(String host, int port, boolean useHttp, boolean encrypt) {
+        _host = host;
+        _port = port;
+        _useHttp = useHttp;
+        _encrypt = encrypt;
+        return this;
     }
 
-    public void loadFromConfigFile(String configFile) throws Exception {
-        loadFromConfigFile(new File(configFile));
+    public MFConfigurationBuilder setServer(String host, int port, String transport) throws Exception {
+        _host = host;
+        _port = port;
+        setTransport(transport);
+        return this;
     }
 
-    public void loadFromConfigFile(File configFile) throws Exception {
-        Properties props = new Properties();
-        InputStream in = new BufferedInputStream(new FileInputStream(configFile));
-        try {
-            props.load(in);
-            if (props.containsKey("host")) {
-                String host = props.getProperty("host");
-                if (host != null) {
-                    host = host.trim();
-                    if (!host.isEmpty()) {
-                        setServerHost(host);
-                    }
-                }
-            }
-            if (props.containsKey("port")) {
-                String portStr = props.getProperty("port");
-                if (portStr != null) {
-                    portStr = portStr.trim();
-                    if (!portStr.isEmpty()) {
-                        int port = Integer.parseInt(portStr);
-                        setServerPort(port);
-                    }
-                }
-            }
-            if (props.containsKey("transport")) {
-                String transport = props.getProperty("transport");
-                if (transport != null) {
-                    transport = transport.trim();
-                    if (!transport.isEmpty()) {
-                        setServerTransport(transport);
-                    }
-                }
-            }
-            if (props.containsKey("domain")) {
-                String domain = props.getProperty("domain");
-                if (domain != null) {
-                    domain = domain.trim();
-                    if (!domain.isEmpty()) {
-                        setDomain(domain);
-                    }
-                }
-            }
-            if (props.containsKey("user")) {
-                String user = props.getProperty("user");
-                if (user != null) {
-                    user = user.trim();
-                    if (!user.isEmpty()) {
-                        setUser(user);
-                    }
-                }
-            }
-            if (props.containsKey("password")) {
-                String password = props.getProperty("password");
-                if (password != null) {
-                    password = password.trim();
-                    if (!password.isEmpty()) {
-                        setPassword(password);
-                    }
-                }
-            }
-            if (props.containsKey("token")) {
-                String token = props.getProperty("token");
-                if (token != null) {
-                    token = token.trim();
-                    if (!token.isEmpty()) {
-                        setToken(token);
-                    }
-                }
-            }
-        } finally {
-            in.close();
-        }
+    public MFConfigurationBuilder setSessionKey(String sessionKey) {
+        _sessionKey = sessionKey;
+        return this;
     }
 
-    // @formatter:off
-    /**
-     * Try finding mflux.cfg file in the following order:
-     *     1. try system property: mf.config
-     *     2. try system environment variable: MFLUX_CFG
-     *     3. try default location: $HOME/.Arcitecta/mflux.cfg
-     * @return the absolute path of the configuration file. if not found, return null.
-     * @throws Throwable
-     */
-    // @formatter:on
-    public String findAndLoadFromConfigFile() throws Throwable {
-        String cfgFile = System.getProperty(PROPERTY_MF_CONFIG);
-
-        /*
-         * try system property: mf.config
-         */
-        if (cfgFile != null) {
-            File f = new File(cfgFile);
-            if (f.exists()) {
-                loadFromConfigFile(f);
-                return f.getAbsolutePath();
-            }
-        }
-
-        /*
-         * try system environment variable: MFLUX_CFG
-         */
-        cfgFile = System.getenv(ENV_MFLUX_CFG);
-        if (cfgFile != null) {
-            File f = new File(cfgFile);
-            if (f.exists()) {
-                loadFromConfigFile(f);
-                return f.getAbsolutePath();
-            }
-        }
+    public MFConfigurationBuilder setToken(String token) {
+        _token = token;
+        return this;
+    }
 
-        /*
-         * try default location: $HOME/.Arcitecta/mflux.cfg
-         */
-        cfgFile = DEFAULT_MFLUX_CFG_FILE;
-        if (cfgFile != null) {
-            File f = new File(cfgFile);
-            if (f.exists()) {
-                loadFromConfigFile(f);
-                return f.getAbsolutePath();
-            }
-        }
-        return null;
+    public MFConfigurationBuilder setTransport(String transport) throws Exception {
+        parseTransport(transport);
+        return this;
     }
 
-    /**
-     * Load the specified Mediaflux configuration file. If the specified file is
-     * null or the file is not found. Try finding and loading the file specified
-     * in 1) system property; 2) system environment variable 3) default
-     * location: $HOME/.Arcitecta/mflux.cfg
-     * 
-     * @param configFile
-     * @throws Throwable
-     */
-    public String loadFromConfigFileOrFind(String configFile) throws Throwable {
-        if (configFile != null) {
-            File cfgFile = new File(configFile);
-            if (cfgFile.exists()) {
-                loadFromConfigFile(cfgFile);
-                return cfgFile.getAbsolutePath();
-            }
-        }
-        return findAndLoadFromConfigFile();
+    public MFConfigurationBuilder setUser(String user) {
+        _user = user;
+        return this;
     }
 
-    public List<String> parseArgs(String[] args) throws Throwable {
-        return parseArgs(args, 0, args.length);
+    public MFConfigurationBuilder setUserCredentials(String domain, String user, String password) {
+        _domain = domain;
+        _user = user;
+        _password = password;
+        return this;
     }
 
-    public List<String> parseArgs(String[] args, int offset, int length) throws Throwable {
+    public String token() {
+        return _token;
+    }
 
-        List<String> remainArgs = new ArrayList<String>();
-        for (int i = offset; i < offset + length;) {
-            if ("--mf.config".equalsIgnoreCase(args[i])) {
-                try {
-                    loadFromConfigFile(args[i + 1]);
-                } catch (Throwable e) {
-                    throw new IllegalArgumentException("Invalid --mf.config: " + args[i + 1], e);
-                }
-                i += 2;
-            } else if ("--mf.host".equalsIgnoreCase(args[i])) {
-                setServerHost(args[i + 1]);
-                i += 2;
-            } else if ("--mf.port".equalsIgnoreCase(args[i])) {
-                setServerPort(Integer.parseInt(args[i + 1]));
-                i += 2;
-            } else if ("--mf.transport".equalsIgnoreCase(args[i])) {
-                setServerTransport(args[i + 1]);
-                i += 2;
-            } else if ("--mf.auth".equalsIgnoreCase(args[i])) {
-                String auth = args[i + 1];
-                String[] parts = auth.split(",");
-                if (parts == null || parts.length != 3) {
-                    throw new IllegalArgumentException("Invalid mf.auth: " + auth);
-                }
-                setUserCredentials(parts[0], parts[1], parts[2]);
-                i += 2;
-            } else if ("--mf.token".equalsIgnoreCase(args[i])) {
-                setToken(args[i + 1]);
-                i += 2;
-            } else {
-                remainArgs.add(args[i]);
-                i++;
-            }
+    public String transport() {
+        if (_useHttp) {
+            return _encrypt ? "HTTPS" : "HTTP";
+        } else {
+            return "TCP_IP";
         }
+    }
 
-        return remainArgs;
+    public boolean useHttp() {
+        return _useHttp;
+    }
+
+    public String user() {
+        return _user;
     }
 
 }
diff --git a/src/main/java/unimelb/mf/client/session/MFRequest.java b/src/main/java/unimelb/mf/client/session/MFRequest.java
new file mode 100644
index 0000000000000000000000000000000000000000..131e46bb84956cb4418d77a21abfbed74e2c76fd
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/session/MFRequest.java
@@ -0,0 +1,34 @@
+package unimelb.mf.client.session;
+
+import java.util.List;
+
+import arc.mf.client.RequestOptions;
+import arc.mf.client.ServerClient;
+import arc.mf.client.ServerRoute;
+import arc.xml.XmlDoc;
+
+public interface MFRequest {
+
+    ServerRoute route();
+
+    String service();
+
+    String args();
+
+    List<ServerClient.Input> inputs();
+
+    ServerClient.Output output();
+
+    RequestOptions options();
+
+    default XmlDoc.Element execute(MFSession session) throws Throwable {
+        return session.execute(route(), service(), args(), inputs(), output(), options());
+    }
+
+    void abort() throws Throwable;
+
+    public static MFRequest create(ServerRoute route, String service, String args, List<ServerClient.Input> inputs,
+            ServerClient.Output output, RequestOptions options) {
+        return new MFRequestBuilder().build(route, service, args, inputs, output, options);
+    }
+}
diff --git a/src/main/java/unimelb/mf/client/session/MFRequestBuilder.java b/src/main/java/unimelb/mf/client/session/MFRequestBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..b8a06756dba901390eb811f1df5f72698b3c35f4
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/session/MFRequestBuilder.java
@@ -0,0 +1,113 @@
+package unimelb.mf.client.session;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import arc.mf.client.RequestOptions;
+import arc.mf.client.ServerClient;
+import arc.mf.client.ServerRoute;
+import arc.mf.client.ServiceSequenceListener;
+import arc.mf.client.ServiceStatusHandler;
+import arc.utils.AbortableOperationHandler;
+
+public class MFRequestBuilder {
+
+    private ServerRoute _route;
+    private String _service;
+    private String _args;
+    private List<ServerClient.Input> _inputs;
+    private ServerClient.Output _output;
+    private RequestOptions _options;
+
+    public MFRequestBuilder() {
+        _options = new RequestOptions();
+    }
+
+    public MFRequestBuilder setRoute(ServerRoute route) {
+        _route = route;
+        return this;
+    }
+
+    public MFRequestBuilder setService(String service) {
+        _service = service;
+        return this;
+    }
+
+    public MFRequestBuilder setArgs(String args) {
+        _args = args;
+        return this;
+    }
+
+    public MFRequestBuilder setInputs(List<ServerClient.Input> inputs) {
+        if (inputs == null || inputs.isEmpty()) {
+            _inputs = null;
+        } else {
+            _inputs = new ArrayList<ServerClient.Input>(inputs);
+        }
+        return this;
+    }
+
+    public MFRequestBuilder addInput(ServerClient.Input input) {
+        if (_inputs == null) {
+            _inputs = new ArrayList<ServerClient.Input>();
+        }
+        _inputs.add(input);
+        return this;
+    }
+
+    public MFRequestBuilder setOutput(ServerClient.Output output) {
+        _output = output;
+        return this;
+    }
+
+    public MFRequestBuilder setOptions(RequestOptions options) {
+        _options = options == null ? new RequestOptions() : options;
+        return this;
+    }
+
+    public MFRequestBuilder setAbortHandler(AbortableOperationHandler ah) {
+        _options.setAbortHandler(ah);
+        return this;
+    }
+
+    public MFRequestBuilder setBackground(boolean background) {
+        _options.setBackground(background);
+        return this;
+    }
+
+    public MFRequestBuilder setDescription(String description) {
+        _options.setDescription(description);
+        return this;
+    }
+
+    public MFRequestBuilder setKey(String key) {
+        _options.setKey(key);
+        return this;
+    }
+
+    public MFRequestBuilder setRetainHours(int retainHours) {
+        _options.setRetainHours(retainHours);
+        return this;
+    }
+
+    public MFRequestBuilder setSequenceListener(ServiceSequenceListener sequenceListener) {
+        _options.setSequenceListener(sequenceListener);
+        return this;
+    }
+
+    public MFRequestBuilder setStatusHandler(ServiceStatusHandler statusHandler, int waitTime) {
+        _options.setStatusHandler(statusHandler, waitTime);
+        return this;
+    }
+
+    public MFRequest build() {
+        return new MFRequestImpl(_route, _service, _args, _inputs, _output, _options);
+    }
+
+    public MFRequest build(ServerRoute route, String service, String args, List<ServerClient.Input> inputs,
+            ServerClient.Output output, RequestOptions options) {
+        return setRoute(route).setService(service).setArgs(args).setInputs(inputs).setOutput(output).setOptions(options)
+                .build();
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/session/MFRequestImpl.java b/src/main/java/unimelb/mf/client/session/MFRequestImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..7de530a2a8b4d81bf8abcd7c50b9d0f41308d86e
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/session/MFRequestImpl.java
@@ -0,0 +1,116 @@
+package unimelb.mf.client.session;
+
+import java.util.Collections;
+import java.util.List;
+
+import arc.mf.client.RequestOptions;
+import arc.mf.client.ServerClient;
+import arc.mf.client.ServerRoute;
+import arc.utils.AbortableOperationHandler;
+import arc.utils.CanAbort;
+
+public class MFRequestImpl implements MFRequest {
+
+    private ServerRoute _route;
+    private String _service;
+    private String _args;
+    private List<ServerClient.Input> _inputs;
+    private ServerClient.Output _output;
+    private RequestOptions _ops;
+    private CanAbort _ca;
+
+    protected MFRequestImpl(ServerRoute route, String service, String args, List<ServerClient.Input> inputs,
+            ServerClient.Output output, RequestOptions options) {
+        _route = route;
+        _service = service;
+        _args = args;
+        _inputs = inputs;
+        _output = output;
+        _ops = copy(options);
+        if (_ops == null) {
+            _ops = new RequestOptions();
+        }
+        final AbortableOperationHandler ah = _ops.abortHandler();
+        _ops.setAbortHandler(new AbortableOperationHandler() {
+
+            @Override
+            public void finished(CanAbort ca) {
+                _ca = null;
+                if (ah != null) {
+                    ah.finished(ca);
+                }
+            }
+
+            @Override
+            public void started(CanAbort ca) {
+                _ca = ca;
+                if (ah != null) {
+                    ah.started(ca);
+                }
+            }
+        });
+    }
+
+    @Override
+    public ServerRoute route() {
+        return _route;
+    }
+
+    @Override
+    public String service() {
+        return _service;
+    }
+
+    @Override
+    public String args() {
+        return _args;
+    }
+
+    @Override
+    public List<ServerClient.Input> inputs() {
+        if (_inputs == null) {
+            return null;
+        } else {
+            return Collections.unmodifiableList(_inputs);
+        }
+    }
+
+    @Override
+    public ServerClient.Output output() {
+        return _output;
+    }
+
+    @Override
+    public RequestOptions options() {
+        if (_ops == null) {
+            return null;
+        } else {
+            // Make a copy to guarantee immutability.
+            return copy(_ops);
+        }
+    }
+
+    @Override
+    public void abort() throws Throwable {
+        if (_ca != null) {
+            _ca.abort();
+            _ca = null;
+        }
+    }
+
+    public static RequestOptions copy(RequestOptions options) {
+        if (options == null) {
+            return null;
+        }
+        RequestOptions ops = new RequestOptions();
+        ops.setAbortHandler(options.abortHandler());
+        ops.setBackground(options.background());
+        ops.setDescription(options.description());
+        ops.setKey(options.key());
+        ops.setRetainHours(options.retainHours());
+        ops.setSequenceListener(options.sequenceListener());
+        ops.setStatusHandler(options.statusHandler(), 1000);
+        return ops;
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/session/MFService.java b/src/main/java/unimelb/mf/client/session/MFService.java
deleted file mode 100644
index 324d3206d9b0eb219b17db835979028ebd74bc0b..0000000000000000000000000000000000000000
--- a/src/main/java/unimelb/mf/client/session/MFService.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package unimelb.mf.client.session;
-
-import java.util.List;
-
-import arc.mf.client.ServerClient;
-import arc.xml.XmlDoc;
-import arc.xml.XmlStringWriter;
-import arc.xml.XmlWriter;
-
-public interface MFService {
-
-    String name();
-
-    void serviceArgs(XmlWriter w) throws Throwable;
-
-    default void validateArgs() throws IllegalArgumentException {
-
-    }
-
-    default List<ServerClient.Input> inputs() {
-        return null;
-    }
-
-    default ServerClient.Output output() {
-        return null;
-    }
-
-    default long executeBackground(MFSession session) throws Throwable {
-        validateArgs();
-        XmlStringWriter w = new XmlStringWriter();
-        w.add("background", true);
-        w.push("service", new String[] { "name", name() });
-        serviceArgs(w);
-        w.pop();
-        return session.execute("service.execute", w.document(), inputs(), output()).longValue("id");
-    }
-
-    default XmlDoc.Element execute(MFSession session) throws Throwable {
-        validateArgs();
-        return session.execute(name(), serviceArgs(), inputs(), output());
-    }
-
-    default String serviceArgs() throws Throwable {
-        XmlStringWriter w = new XmlStringWriter();
-        serviceArgs(w);
-        return w.document();
-    }
-
-}
diff --git a/src/main/java/unimelb/mf/client/session/MFSession.java b/src/main/java/unimelb/mf/client/session/MFSession.java
index 658414b626516d991675cbaa0304db6bbccb267e..38ba94dd3ff60ab32cb8364c357e5363a78eb63d 100644
--- a/src/main/java/unimelb/mf/client/session/MFSession.java
+++ b/src/main/java/unimelb/mf/client/session/MFSession.java
@@ -7,14 +7,14 @@ import java.util.Timer;
 import java.util.TimerTask;
 
 import arc.mf.client.AuthenticationDetails;
+import arc.mf.client.ConnectionDetails;
 import arc.mf.client.RemoteServer;
 import arc.mf.client.RequestOptions;
 import arc.mf.client.ServerClient;
 import arc.mf.client.ServerClient.Input;
+import arc.mf.client.ServerRoute;
 import arc.utils.AbortableOperationHandler;
-import arc.utils.CanAbort;
 import arc.xml.XmlDoc;
-import unimelb.mf.client.util.HasAbortableOperation;
 
 public class MFSession {
 
@@ -23,195 +23,181 @@ public class MFSession {
     public static final int DEFAULT_EXECUTE_RETRY_TIMES = 1;
     public static final int DEFAULT_EXECUTE_RETRY_INTERVAL = 0;
 
-    private MFConnectionSettings _settings;
-
     private RemoteServer _rs;
-    private AuthenticationDetails _auth;
-    private String _sessionId;
+    private AuthenticationDetails _ad;
+    private String _sessionKey;
+
+    private int _connectRetryTimes = DEFAULT_CONNECT_RETRY_TIMES;
+    private int _connectRetryInterval = DEFAULT_CONNECT_RETRY_INTERVAL;
+    private int _executeRetryTimes = DEFAULT_EXECUTE_RETRY_TIMES;
+    private int _executeRetryInterval = DEFAULT_EXECUTE_RETRY_INTERVAL;
+
     private Timer _timer;
 
-    public MFSession(MFConnectionSettings settings) {
-        _settings = settings;
+    protected MFSession(RemoteServer rs, AuthenticationDetails ad, String sessionKey, int connectRetryTimes,
+            int connectRetryInterval, int executeRetryTimes, int executeRetryInterval) {
+        _rs = rs;
+        _ad = ad;
+        _sessionKey = sessionKey;
+
+        _connectRetryTimes = connectRetryTimes;
+        _connectRetryInterval = connectRetryInterval;
+        _executeRetryTimes = executeRetryTimes;
+        _executeRetryInterval = executeRetryInterval;
     }
 
-    public synchronized String sessionId() {
-        return _sessionId;
+    public void authenticate(AuthenticationDetails authenticationDetails) throws Throwable {
+        ServerClient.Connection cxn = null;
+        try {
+            cxn = connect(_rs.connectionDetails(), authenticationDetails);
+        } finally {
+            if (cxn != null) {
+                cxn.close(_rs.usingConnectionPooling() ? false : true);
+            }
+        }
     }
 
-    private synchronized void setSessionId(String sessionId) {
-        _sessionId = sessionId;
+    public ServerClient.Connection connect(ConnectionDetails connectionDetails,
+            AuthenticationDetails authenticationDetails) throws Throwable {
+        return connect(connectionDetails, authenticationDetails, _connectRetryTimes);
     }
 
     public ServerClient.Connection connect() throws Throwable {
-        return connect(_settings.connectRetryTimes());
+        return connect(_rs.connectionDetails(), _ad, _connectRetryTimes);
     }
 
-    private synchronized ServerClient.Connection connect(int retryTimes) throws Throwable {
-        if (_rs == null) {
-            _rs = new RemoteServer(_settings.serverHost(), _settings.serverPort(), _settings.useHttp(),
-                    _settings.encrypt());
-            _rs.setConnectionPooling(_settings.connectionPooling());
-            _rs.enableConnectionPooling();
+    private synchronized ServerClient.Connection connect(ConnectionDetails connectionDetails,
+            AuthenticationDetails authenticationDetails, int retryTimes) throws Throwable {
+        if (!_rs.connectionDetails().equals(connectionDetails)) {
+            boolean connectionPooling = _rs.usingConnectionPooling();
+            _rs.discard();
+
+            _rs = new RemoteServer(connectionDetails);
+            _rs.setConnectionPooling(connectionPooling);
         }
         ServerClient.Connection cxn = null;
         try {
             cxn = _rs.open();
-            String sessionId = sessionId();
-            if (sessionId != null) {
-                cxn.reconnect(sessionId);
+            if (_sessionKey != null && AuthenticationDetailsUtils.equals(_ad, authenticationDetails)) {
+                cxn.reconnect(_sessionKey);
             } else {
-                setSessionId(cxn.connect(_auth == null ? _settings.authenticationDetails() : _auth));
+                _sessionKey = cxn.connect(authenticationDetails);
+                _ad = authenticationDetails;
             }
-            _auth = cxn.authenticationDetails();
             return cxn;
         } catch (java.net.ConnectException ce) {
             if (retryTimes > 0) {
                 ce.printStackTrace();
                 System.out.println(
                         "Failed to connect Mediaflux server: " + _rs.host() + ":" + _rs.port() + ". Retrying ...");
-                if (_settings.connectRetryInterval() > 0) {
-                    Thread.sleep(_settings.connectRetryInterval());
+                if (_connectRetryInterval > 0) {
+                    Thread.sleep(_connectRetryInterval);
                 }
-                return connect(--retryTimes);
+                return connect(connectionDetails, authenticationDetails, --retryTimes);
             } else {
                 throw ce;
             }
         }
     }
 
-    public XmlDoc.Element execute(String service, String args, ServerClient.Input input, ServerClient.Output output,
-            HasAbortableOperation abortable) throws Throwable {
-        return execute(service, args, input == null ? null : Arrays.asList(input), output, abortable);
+    public XmlDoc.Element execute(ServerRoute serverRoute, String service, String args, ServerClient.Input input,
+            ServerClient.Output output, RequestOptions options) throws Throwable {
+        return execute(serverRoute, service, args, input == null ? null : Arrays.asList(input), output, options);
     }
 
-    public XmlDoc.Element execute(String service, String args, List<ServerClient.Input> inputs,
-            ServerClient.Output output, HasAbortableOperation abortable) throws Throwable {
-        ServerClient.Connection cxn = connect();
-        try {
-            return execute(cxn, service, args, inputs, output, abortable, _settings.executeRetryTimes());
-        } finally {
-            // If connection pooling is enabled, do not release resources so
-            // that the connection is kept in the pool to be reused.
-            cxn.close(_rs.usingConnectionPooling() ? false : true);
-        }
+    public XmlDoc.Element execute(ServerRoute serverRoute, String service, String args, ServerClient.Input input,
+            ServerClient.Output output, AbortableOperationHandler abortHandler) throws Throwable {
+        return execute(serverRoute, service, args, input == null ? null : Arrays.asList(input), output, abortHandler);
     }
 
-    public void testAuthentication() throws Throwable {
-        ServerClient.Connection cxn = null;
-        try {
-            cxn = connect();
-        } finally {
-            if (cxn != null) {
-                cxn.close(_rs.usingConnectionPooling() ? false : true);
-            }
+    public XmlDoc.Element execute(ServerRoute serverRoute, String service, String args, List<ServerClient.Input> inputs,
+            ServerClient.Output output, AbortableOperationHandler abortHandler) throws Throwable {
+        RequestOptions options = null;
+        if (abortHandler != null) {
+            options = new RequestOptions();
+            options.setAbortHandler(abortHandler);
         }
+        return execute(MFRequest.create(serverRoute, service, args, inputs, output, options));
     }
 
-    public void doConsoleLogon() throws Throwable {
-        Console console = System.console();
-        if (console == null) {
-            throw new UnsupportedOperationException("Failed to open system console");
-        }
-        if (_settings.serverHost() == null) {
-            _settings.readServerHostFromConsole(console);
-        } else {
-            console.printf("Host: %s%n", _settings.serverHost());
-        }
-        if (_settings.serverPort() <= 0) {
-            _settings.readServerPortFromConsole(console);
-        } else {
-            console.printf("Port: %d%n", _settings.serverPort());
-        }
-        if (_settings.serverTransport() == null) {
-            _settings.readServerTransportFromConsole(console);
-        } else {
-            console.printf("Transport: %s%n", _settings.serverTransport());
-        }
-        if (_settings.domain() == null) {
-            _settings.readDomainFromConsole(console);
-        } else {
-            console.printf("Domain: %s%n", _settings.domain());
-        }
-        if (_settings.user() == null) {
-            _settings.readUserFromConsole(console);
-        } else {
-            console.printf("User: %s%n", _settings.user());
-        }
-        _settings.readPasswordFromConsole(console);
+    public XmlDoc.Element execute(ServerRoute serverRoute, String service, String args, List<ServerClient.Input> inputs,
+            ServerClient.Output output, RequestOptions options) throws Throwable {
+        return execute(MFRequest.create(serverRoute, service, args, inputs, output, options));
+    }
 
+    public XmlDoc.Element execute(MFRequest request) throws Throwable {
+        ServerClient.Connection cxn = connect();
         try {
-            testAuthentication();
-        } catch (Throwable e) {
-            console.printf("%n");
-            console.printf("Error: %s%n", e.getMessage());
-            console.printf("%n");
-            doConsoleLogon();
+            return execute(cxn, request, _executeRetryTimes);
+        } finally {
+            // If connection pooling is enabled, do not release resources so
+            // that the connection is kept in the pool to be reused.
+            cxn.close(_rs.usingConnectionPooling() ? false : true);
         }
     }
 
-    protected XmlDoc.Element execute(ServerClient.Connection cxn, String service, String args,
-            List<ServerClient.Input> inputs, ServerClient.Output output, HasAbortableOperation abortable,
-            int retryTimes) throws Throwable {
+    protected XmlDoc.Element execute(ServerClient.Connection cxn, MFRequest request, int retryTimes) throws Throwable {
         try {
-            RequestOptions ops = new RequestOptions();
-            ops.setAbortHandler(new AbortableOperationHandler() {
-
-                @Override
-                public void finished(CanAbort ca) {
-                    if (abortable != null) {
-                        abortable.setAbortableOperation(null);
-                    }
-                }
-
-                @Override
-                public void started(CanAbort ca) {
-                    if (abortable != null) {
-                        abortable.setAbortableOperation(ca);
-                    }
-                }
-            });
-            return cxn.executeMultiInput(null, service, args, inputs, output, ops);
+            return cxn.executeMultiInput(request.route(), request.service(), request.args(), request.inputs(),
+                    request.output(), request.options());
         } catch (ServerClient.ExSessionInvalid si) {
-            if (_auth != null && retryTimes > 0) {
+            if (retryTimes > 0) {
                 System.out.println("Session invalid. Try re-authenticating...");
-                if (_settings.executeRetryInterval() > 0) {
-                    Thread.sleep(_settings.executeRetryInterval());
+                if (_executeRetryInterval > 0) {
+                    Thread.sleep(_executeRetryInterval);
+                }
+                synchronized (this) {
+                    _sessionKey = cxn.connect(_ad);
                 }
-                setSessionId(cxn.connect(_auth));
-                return execute(cxn, service, args, inputs, output, abortable, --retryTimes);
+                return execute(cxn, request, --retryTimes);
             }
             throw si;
         }
     }
 
-    public XmlDoc.Element execute(String service, String args, List<ServerClient.Input> inputs,
+    public XmlDoc.Element execute(ServerRoute route, String service, String args, List<ServerClient.Input> inputs,
             ServerClient.Output output) throws Throwable {
-        return execute(service, args, inputs, output, null);
+        return execute(route, service, args, inputs, output, (RequestOptions) null);
     }
 
     public XmlDoc.Element execute(String service, String args, List<ServerClient.Input> inputs) throws Throwable {
-        return execute(service, args, inputs, null, null);
+        return execute(null, service, args, inputs, null, (RequestOptions) null);
     }
 
     public XmlDoc.Element execute(String service, String args, ServerClient.Input input, ServerClient.Output output)
             throws Throwable {
-        return execute(service, args, input, output, null);
+        return execute(null, service, args, input, output, (RequestOptions) null);
     }
 
     public XmlDoc.Element execute(String service, String args, ServerClient.Input input) throws Throwable {
-        return execute(service, args, input, null, null);
+        return execute(null, service, args, input, null, (RequestOptions) null);
     }
 
     public XmlDoc.Element execute(String service, String args, ServerClient.Output output) throws Throwable {
-        return execute(service, args, (List<Input>) null, output, null);
+        return execute(null, service, args, (List<Input>) null, output, (RequestOptions) null);
     }
 
     public XmlDoc.Element execute(String service, String args) throws Throwable {
-        return execute(service, args, (List<Input>) null, null, null);
+        return execute(null, service, args, (List<Input>) null, null, (RequestOptions) null);
     }
 
     public XmlDoc.Element execute(String service) throws Throwable {
-        return execute(service, null, (List<Input>) null, null, null);
+        return execute(null, service, null, (List<Input>) null, null, (RequestOptions) null);
+    }
+
+    public XmlDoc.Element execute(String service, String args, List<ServerClient.Input> inputs,
+            ServerClient.Output output, AbortableOperationHandler abortHandler) throws Throwable {
+        return execute(null, service, args, inputs, output, abortHandler);
+    }
+
+    public XmlDoc.Element execute(String service, String args, ServerClient.Input input,
+            AbortableOperationHandler abortHandler) throws Throwable {
+        return execute(null, service, args, input == null ? null : Arrays.asList(input), null, abortHandler);
+    }
+
+    public XmlDoc.Element execute(String service, String args, ServerClient.Output output,
+            AbortableOperationHandler abortHandler) throws Throwable {
+        return execute(null, service, args, (List<ServerClient.Input>) null, output, abortHandler);
     }
 
     public void discard() {
@@ -250,8 +236,84 @@ public class MFSession {
         }
     }
 
-    public String serverHost() {
-        return _settings.serverHost();
+    public static MFSession create(MFConfigurationBuilder config) throws Throwable {
+        if (config.hasMissingArgument()) {
+            if (!config.consoleLogon()) {
+                throw new IllegalArgumentException("Missing Mediaflux server or user details.");
+            }
+            Console console = System.console();
+            if (console == null) {
+                throw new UnsupportedOperationException("Failed to open system console");
+            }
+            if (config.host() == null) {
+                config.readServerHostFromConsole(console);
+            } else {
+                console.printf("Host: %s%n", config.host());
+            }
+            if (config.port() <= 0) {
+                config.readServerPortFromConsole(console);
+            } else {
+                console.printf("Port: %d%n", config.port());
+            }
+            if (config.transport() == null) {
+                config.readServerTransportFromConsole(console);
+            } else {
+                console.printf("Transport: %s%n", config.transport());
+            }
+            if (config.domain() == null) {
+                config.readDomainFromConsole(console);
+            } else {
+                console.printf("Domain: %s%n", config.domain());
+            }
+            if (config.user() == null) {
+                config.readUserFromConsole(console);
+            } else {
+                console.printf("User: %s%n", config.user());
+            }
+            config.readPasswordFromConsole(console);
+            RemoteServer rs = null;
+            ServerClient.Connection c = null;
+            try {
+                rs = new RemoteServer(config.connectionDetails());
+                rs.setConnectionPooling(config.connectionPooling());
+                c = rs.open();
+                try {
+                    String session = c.connect(config.authenticationDetails());
+                    return new MFSession(rs, c.authenticationDetails(), session, config.connectRetryTimes(),
+                            config.connectRetryInterval(), config.executeRetryTimes(), config.executeRetryInterval());
+                } finally {
+                    c.close(rs.usingConnectionPooling() ? false : true);
+                }
+            } catch (Throwable e) {
+                console.printf("%n");
+                console.printf("Error: %s%n", e.getMessage());
+                console.printf("%n");
+                if (c != null) {
+                    try {
+                        c.closeAndDiscard();
+                    } catch (Throwable t) {
+                    }
+                }
+                if (rs != null) {
+                    try {
+                        rs.discard();
+                    } catch (Throwable t) {
+                    }
+                }
+                return create(config);
+            }
+        } else {
+            RemoteServer rs = new RemoteServer(config.connectionDetails());
+            rs.setConnectionPooling(config.connectionPooling());
+            ServerClient.Connection c = rs.open();
+            try {
+                String session = c.connect(config.authenticationDetails());
+                return new MFSession(rs, c.authenticationDetails(), session, config.connectRetryTimes(),
+                        config.connectRetryInterval(), config.executeRetryTimes(), config.executeRetryInterval());
+            } finally {
+                c.close(rs.usingConnectionPooling() ? false : true);
+            }
+        }
     }
 
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/AbstractSSHService.java b/src/main/java/unimelb/mf/client/ssh/AbstractSSHService.java
deleted file mode 100644
index b452807fa659a07afd1520102f9fa228047e7014..0000000000000000000000000000000000000000
--- a/src/main/java/unimelb/mf/client/ssh/AbstractSSHService.java
+++ /dev/null
@@ -1,101 +0,0 @@
-package unimelb.mf.client.ssh;
-
-import arc.xml.XmlWriter;
-
-public abstract class AbstractSSHService implements SSHService {
-
-    protected SSHConnectionSettings sshSettings;
-    protected boolean stopOnError = true;
-    protected int retryOnError = 0;
-
-    protected AbstractSSHService() {
-        this.sshSettings = new SSHConnectionSettings();
-    }
-
-    @Override
-    public void serviceArgs(XmlWriter w) throws Throwable {
-        w.add("host", this.sshSettings.host());
-        w.add("port", this.sshSettings.port());
-        w.add("user", this.sshSettings.username());
-        if (this.sshSettings.password() != null) {
-            w.add("password", this.sshSettings.password());
-        }
-        if (this.sshSettings.privateKey() != null) {
-            w.add("private-key", this.sshSettings.privateKey());
-        }
-        if (this.sshSettings.passphrase() != null) {
-            w.add("passphrase", this.sshSettings.passphrase());
-        }
-        if (!this.stopOnError || this.retryOnError > 0) {
-            w.add("on-error", new String[] { "retry", Integer.toString(this.retryOnError) },
-                    this.stopOnError ? "stop" : "continue");
-        }
-
-    }
-
-    @Override
-    public void setStopOnError(boolean stopOnError) {
-        this.stopOnError = stopOnError;
-    }
-
-    @Override
-    public void setSSHServerHost(String sshServerHost) {
-        this.sshSettings.setServerHost(sshServerHost);
-    }
-
-    @Override
-    public void setSSHServerPort(int sshServerPort) {
-        this.sshSettings.setServerPort(sshServerPort);
-    }
-
-    @Override
-    public void setSSHUsername(String username) {
-        this.sshSettings.setUsername(username);
-    }
-
-    @Override
-    public void setSSHPassword(String password) {
-        this.sshSettings.setPassword(password);
-    }
-
-    @Override
-    public void setSSHBaseDirectory(String baseDir) {
-        this.sshSettings.setBaseDirectory(baseDir);
-    }
-
-    @Override
-    public void setSSHPrivateKey(String privateKey) {
-        this.sshSettings.setPrivateKey(privateKey);
-    }
-
-    @Override
-    public void setSSHPassphrase(String passphrase) {
-        this.sshSettings.setPassphrase(passphrase);
-    }
-
-    @Override
-    public final int retryOnError() {
-        return this.retryOnError;
-    }
-
-    @Override
-    public final void setRetryOnError(int retryOnError) {
-        this.retryOnError = retryOnError;
-    }
-
-    @Override
-    public void validateArgs() throws IllegalArgumentException {
-        this.sshSettings.validate();
-    }
-
-    @Override
-    public boolean stopOnError() {
-        return this.stopOnError;
-    }
-
-    @Override
-    public boolean continueOnError() {
-        return !this.stopOnError;
-    }
-
-}
diff --git a/src/main/java/unimelb/mf/client/ssh/SCPGetService.java b/src/main/java/unimelb/mf/client/ssh/SCPGetService.java
index 1650884a037a0563b1ab8ce8b4ddf6bea61acc65..632fde23b8037cf6d11c5360e80302ed1154fb19 100644
--- a/src/main/java/unimelb/mf/client/ssh/SCPGetService.java
+++ b/src/main/java/unimelb/mf/client/ssh/SCPGetService.java
@@ -2,19 +2,15 @@ package unimelb.mf.client.ssh;
 
 public class SCPGetService extends SSHGetService {
 
-    @Override
-    public final String name() {
-        return "unimelb.scp.get";
-    }
+    public static final String SERVICE_NAME = "unimelb.scp.get";
 
-    @Override
-    public final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SCP;
+    public SCPGetService(SSHGetConfiguration cfg) {
+        super(cfg);
     }
 
     @Override
-    public final SSHTransferDirection sshTransferDirection() {
-        return SSHTransferDirection.GET;
+    public final String service() {
+        return SERVICE_NAME;
     }
 
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/SCPPutService.java b/src/main/java/unimelb/mf/client/ssh/SCPPutService.java
index 2e12d9c9f623a3ac35ef073a16d3e915780c9888..6be3920b86b7a852d63b185d6c2becd8a186e91a 100644
--- a/src/main/java/unimelb/mf/client/ssh/SCPPutService.java
+++ b/src/main/java/unimelb/mf/client/ssh/SCPPutService.java
@@ -2,18 +2,15 @@ package unimelb.mf.client.ssh;
 
 public class SCPPutService extends SSHPutService {
 
-    @Override
-    public final String name() {
-        return "unimelb.scp.put";
-    }
+    public static final String SERVICE_NAME = "unimelb.scp.put";
 
-    @Override
-    public final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SCP;
+    public SCPPutService(SSHPutConfiguration cfg) {
+        super(cfg);
     }
 
     @Override
-    public final SSHTransferDirection sshTransferDirection() {
-        return SSHTransferDirection.PUT;
+    public final String service() {
+        return SERVICE_NAME;
     }
+
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/SFTPGetService.java b/src/main/java/unimelb/mf/client/ssh/SFTPGetService.java
index 8bd947b1a263c4160595c81ba04b5508821ed850..c63db3999c0d6f63bde3124363be27398b6b79f5 100644
--- a/src/main/java/unimelb/mf/client/ssh/SFTPGetService.java
+++ b/src/main/java/unimelb/mf/client/ssh/SFTPGetService.java
@@ -2,18 +2,15 @@ package unimelb.mf.client.ssh;
 
 public class SFTPGetService extends SSHGetService {
 
-    @Override
-    public final String name() {
-        return "unimelb.sftp.get";
-    }
+    public static final String SERVICE_NAME = "unimelb.sftp.get";
 
-    @Override
-    public final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SFTP;
+    public SFTPGetService(SSHGetConfiguration cfg) {
+        super(cfg);
     }
 
     @Override
-    public final SSHTransferDirection sshTransferDirection() {
-        return SSHTransferDirection.GET;
+    public final String service() {
+        return SERVICE_NAME;
     }
+
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/SFTPPutService.java b/src/main/java/unimelb/mf/client/ssh/SFTPPutService.java
index 9d0d1d898021e48af84d7dff49b686f9ee72dc8a..fc24d4dcdc99c60c047cb474c6e5ea468c7d7a1e 100644
--- a/src/main/java/unimelb/mf/client/ssh/SFTPPutService.java
+++ b/src/main/java/unimelb/mf/client/ssh/SFTPPutService.java
@@ -2,18 +2,14 @@ package unimelb.mf.client.ssh;
 
 public class SFTPPutService extends SSHPutService {
 
-    @Override
-    public String name() {
-        return "unimelb.sftp.put";
-    }
+    public static final String SERVICE_NAME = "unimelb.sftp.put";
 
-    @Override
-    public final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SFTP;
+    public SFTPPutService(SSHPutConfiguration cfg) {
+        super(cfg);
     }
 
     @Override
-    public final SSHTransferDirection sshTransferDirection() {
-        return SSHTransferDirection.PUT;
+    public final String service() {
+        return SERVICE_NAME;
     }
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHConfiguration.java b/src/main/java/unimelb/mf/client/ssh/SSHConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..d2434fe18de229022e41e7840baa30d44aa783d4
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/ssh/SSHConfiguration.java
@@ -0,0 +1,91 @@
+package unimelb.mf.client.ssh;
+
+import arc.xml.XmlWriter;
+
+public abstract class SSHConfiguration {
+
+    public static enum Action {
+        GET, PUT
+    }
+
+    private String _host;
+    private int _port = 22;
+    private String _username;
+    private String _password;
+    private String _privateKey;
+    private String _passphrase;
+    private String _baseDir;
+    private boolean _stopOnError = true;
+    private int _retries = 0;
+
+    SSHConfiguration(String host, int port, String username, String password, String privateKey, String passphrase,
+            String baseDir, boolean stopOnError, int retries) {
+        _host = host;
+        _port = port;
+        _username = username;
+        _password = password;
+        _privateKey = privateKey;
+        _passphrase = passphrase;
+        _baseDir = baseDir;
+        _stopOnError = stopOnError;
+        _retries = retries;
+    }
+
+    public String host() {
+        return _host;
+    }
+
+    public int port() {
+        return _port;
+    }
+
+    public String username() {
+        return _username;
+    }
+
+    public String password() {
+        return _password;
+    }
+
+    public String privateKey() {
+        return _privateKey;
+    }
+
+    public String passphrase() {
+        return _passphrase;
+    }
+
+    public String baseDirectory() {
+        return _baseDir;
+    }
+
+    public int retries() {
+        return _retries;
+    }
+
+    public boolean stopOnError() {
+        return _stopOnError;
+    }
+
+    public void save(XmlWriter w) throws Throwable {
+        w.add("host", host());
+        w.add("port", port());
+        w.add("user", username());
+        if (password() != null) {
+            w.add("password", password());
+        }
+        if (privateKey() != null) {
+            w.add("private-key", privateKey());
+        }
+        if (passphrase() != null) {
+            w.add("passphrase", passphrase());
+        }
+        if (!stopOnError() || retries() > 0) {
+            w.add("on-error", new String[] { "retry", Integer.toString(retries()) },
+                    stopOnError() ? "stop" : "continue");
+        }
+    }
+
+    protected abstract Action action();
+
+}
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHConfigurationBuilder.java b/src/main/java/unimelb/mf/client/ssh/SSHConfigurationBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..614ee70f86196d0fafee397b12513b74d453a7dc
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/ssh/SSHConfigurationBuilder.java
@@ -0,0 +1,92 @@
+package unimelb.mf.client.ssh;
+
+import unimelb.mf.client.ssh.SSHConfiguration.Action;
+
+@SuppressWarnings("unchecked")
+public abstract class SSHConfigurationBuilder<T extends SSHConfiguration, B extends SSHConfigurationBuilder<T, B>> {
+
+    protected String host;
+    protected int port = 22;
+    protected String username;
+    protected String password;
+    protected String privateKey;
+    protected String passphrase;
+    protected String baseDirectory;
+    protected boolean stopOnError = true;;
+    protected int retries = 0;
+
+    protected SSHConfigurationBuilder() {
+
+    }
+
+    public B setHost(String host) {
+        this.host = host;
+        return (B) this;
+    }
+
+    public B setPort(int port) {
+        this.port = port;
+        return (B) this;
+    }
+
+    public B setUsername(String username) {
+        this.username = username;
+        return (B) this;
+    }
+
+    public B setPassword(String password) {
+        this.password = password;
+        return (B) this;
+    }
+
+    public B setPrivateKey(String privateKey, String passphrase) {
+        this.privateKey = privateKey;
+        this.passphrase = passphrase;
+        return (B) this;
+    }
+
+    public B setPrivateKey(String privateKey) {
+        this.privateKey = privateKey;
+        return (B) this;
+    }
+
+    public B setPassphrase(String passphrase) {
+        this.passphrase = passphrase;
+        return (B) this;
+    }
+
+    public B setBaseDirectory(String baseDirectory) {
+        this.baseDirectory = baseDirectory;
+        return (B) this;
+    }
+
+    public B setStopOnError(boolean stopOnError) {
+        this.stopOnError = stopOnError;
+        return (B) this;
+    }
+
+    public B setRetries(int retries) {
+        this.retries = retries > 0 ? retries : 0;
+        return (B) this;
+    }
+
+    public void validate() throws IllegalArgumentException {
+        if (this.host == null) {
+            throw new IllegalArgumentException("Missing SSH server host.");
+        }
+        if (this.port <= 0 || this.port > 65535) {
+            throw new IllegalArgumentException("Invalid SSH server port: " + this.port);
+        }
+        if (this.username == null) {
+            throw new IllegalArgumentException("Missing SSH username.");
+        }
+        if (this.password == null && this.privateKey == null) {
+            throw new IllegalArgumentException("Missing SSH password or private key. Expects at least one.");
+        }
+    }
+
+    public abstract Action action();
+
+    public abstract T build();
+
+}
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHConnectionSettings.java b/src/main/java/unimelb/mf/client/ssh/SSHConnectionSettings.java
deleted file mode 100644
index ab7b0e47145b8371d5de189c374332f741f7cd34..0000000000000000000000000000000000000000
--- a/src/main/java/unimelb/mf/client/ssh/SSHConnectionSettings.java
+++ /dev/null
@@ -1,101 +0,0 @@
-package unimelb.mf.client.ssh;
-
-public class SSHConnectionSettings {
-
-    private String _host;
-    private int _port = 22;
-    private String _username;
-    private String _password;
-    private String _privateKey;
-    private String _passphrase;
-    private String _baseDir;
-
-    public SSHConnectionSettings() {
-
-    }
-
-    public SSHConnectionSettings setServerHost(String host) {
-        _host = host;
-        return this;
-    }
-
-    public SSHConnectionSettings setServerPort(int port) {
-        _port = port;
-        return this;
-    }
-
-    public SSHConnectionSettings setUsername(String username) {
-        _username = username;
-        return this;
-    }
-
-    public SSHConnectionSettings setPassword(String password) {
-        _password = password;
-        return this;
-    }
-
-    public SSHConnectionSettings setPrivateKey(String privateKey, String passphrase) {
-        _privateKey = privateKey;
-        _passphrase = passphrase;
-        return this;
-    }
-
-    public SSHConnectionSettings setPrivateKey(String privateKey) {
-        _privateKey = privateKey;
-        return this;
-    }
-
-    public SSHConnectionSettings setPassphrase(String passphrase) {
-        _passphrase = passphrase;
-        return this;
-    }
-
-    public SSHConnectionSettings setBaseDirectory(String baseDirectory) {
-        _baseDir = baseDirectory;
-        return this;
-    }
-
-    public String host() {
-        return _host;
-    }
-
-    public int port() {
-        return _port;
-    }
-
-    public String username() {
-        return _username;
-    }
-
-    public String password() {
-        return _password;
-    }
-
-    public String privateKey() {
-        return _privateKey;
-    }
-
-    public String passphrase() {
-        return _passphrase;
-    }
-
-    public String baseDirectory() {
-        return _baseDir;
-    }
-
-    public void validate() throws IllegalArgumentException {
-        if (_host == null) {
-            throw new IllegalArgumentException("Missing SSH server host.");
-        }
-        if (_port <= 0 || _port > 65535) {
-            throw new IllegalArgumentException("Invalid SSH server port: " + _port);
-        }
-        if (_username == null) {
-            throw new IllegalArgumentException("Missing SSH username.");
-        }
-        if (_password == null && _privateKey == null) {
-            throw new IllegalArgumentException("Missing SSH password or private key. Expects at least one.");
-        }
-    }
-
-}
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHGetConfiguration.java b/src/main/java/unimelb/mf/client/ssh/SSHGetConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..76b288a0bc2a3dfcc3c9708ed3c407e9f1681da2
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/ssh/SSHGetConfiguration.java
@@ -0,0 +1,74 @@
+package unimelb.mf.client.ssh;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import arc.xml.XmlWriter;
+import unimelb.mf.model.asset.worm.Worm;
+
+public class SSHGetConfiguration extends SSHConfiguration {
+
+    private Boolean _readOnly = null;
+    private Worm _worm = null;
+    private Set<String> _srcPaths;
+    private String _dstNamespace = null;
+
+    SSHGetConfiguration(String host, int port, String username, String password, String privateKey, String passphrase,
+            String baseDir, boolean stopOnError, int retries, Collection<String> srcPaths, String dstNamespace,
+            Boolean readOnly, Worm worm) {
+        super(host, port, username, password, privateKey, passphrase, baseDir, stopOnError, retries);
+        _srcPaths = new LinkedHashSet<String>();
+        if (srcPaths != null) {
+            _srcPaths.addAll(srcPaths);
+        }
+        _dstNamespace = dstNamespace;
+        _readOnly = readOnly;
+
+    }
+
+    @Override
+    public final Action action() {
+        return Action.GET;
+    }
+
+    public String dstNamespace() {
+        return _dstNamespace;
+    }
+
+    public Collection<String> srcPaths() {
+        return Collections.unmodifiableSet(_srcPaths);
+    }
+
+    public Boolean readOnly() {
+        return _readOnly;
+    }
+
+    public Worm worm() {
+        return _worm;
+    }
+
+    @Override
+    public void save(XmlWriter w) throws Throwable {
+        super.save(w);
+        if (readOnly() != null) {
+            w.add("read-only", readOnly());
+        }
+        if (worm() != null) {
+            w.push("worm");
+            worm().save(w);
+            w.pop();
+        }
+        if (dstNamespace() != null) {
+            w.add("namespace", dstNamespace());
+        }
+        Collection<String> srcPaths = srcPaths();
+        if (srcPaths != null) {
+            for (String srcPath : srcPaths) {
+                w.add("path", srcPath);
+            }
+        }
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHGetConfigurationBuilder.java b/src/main/java/unimelb/mf/client/ssh/SSHGetConfigurationBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..aad62851f169fc82cc7292335da14a6172d49904
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/ssh/SSHGetConfigurationBuilder.java
@@ -0,0 +1,87 @@
+package unimelb.mf.client.ssh;
+
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Set;
+
+import unimelb.mf.client.ssh.SSHConfiguration.Action;
+import unimelb.mf.model.asset.worm.Worm;
+
+public class SSHGetConfigurationBuilder
+        extends SSHConfigurationBuilder<SSHGetConfiguration, SSHGetConfigurationBuilder> {
+
+    protected Boolean readOnly = null;
+    protected Worm worm = null;
+    protected Set<String> srcPaths;
+    protected String dstNamespace = null;
+
+    public SSHGetConfigurationBuilder() {
+        this.srcPaths = new LinkedHashSet<String>();
+    }
+
+    public void validate() throws IllegalArgumentException {
+        super.validate();
+        if (this.srcPaths.isEmpty()) {
+            throw new IllegalArgumentException("Missing path on the remote SSH server.");
+        }
+        if (this.dstNamespace == null) {
+            throw new IllegalArgumentException("Missing namespace path on the Mediaflux server.");
+        }
+    }
+
+    @Override
+    public SSHGetConfiguration build() {
+        validate();
+        return new SSHGetConfiguration(this.host, this.port, this.username, this.password, this.privateKey,
+                this.passphrase, this.baseDirectory, this.stopOnError, this.retries, this.srcPaths, this.dstNamespace,
+                this.readOnly, this.worm);
+    }
+
+    public SSHGetConfigurationBuilder setReadOnly(boolean readOnly) {
+        this.readOnly = readOnly;
+        return this;
+    }
+
+    public Worm worm() {
+        return this.worm;
+    }
+
+    public SSHGetConfigurationBuilder setWorm(Worm worm) {
+        this.worm = worm;
+        return this;
+    }
+
+    public SSHGetConfigurationBuilder setSrcPath(String path) {
+        this.srcPaths.clear();
+        if (path != null) {
+            this.srcPaths.add(path);
+        }
+        return this;
+    }
+
+    public SSHGetConfigurationBuilder setSrcPaths(Collection<String> paths) {
+        this.srcPaths.clear();
+        if (paths != null) {
+            this.srcPaths.addAll(paths);
+        }
+        return this;
+    }
+
+    public SSHGetConfigurationBuilder addSrcPath(String path) {
+        if (path != null) {
+            this.srcPaths.add(path);
+        }
+        return this;
+    }
+
+    public SSHGetConfigurationBuilder setDstNamespace(String namespace) {
+        this.dstNamespace = namespace;
+        return this;
+    }
+
+    @Override
+    public final Action action() {
+        return Action.GET;
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHGetService.java b/src/main/java/unimelb/mf/client/ssh/SSHGetService.java
index 8752f20bec05b0f36381796f2c00d9e6100b5811..a7eeb4b87b1e7da7d53322c04bdbae32056371f2 100644
--- a/src/main/java/unimelb/mf/client/ssh/SSHGetService.java
+++ b/src/main/java/unimelb/mf/client/ssh/SSHGetService.java
@@ -1,98 +1,8 @@
 package unimelb.mf.client.ssh;
 
-import java.util.Collection;
-import java.util.Collections;
-import java.util.LinkedHashSet;
-import java.util.Set;
+public abstract class SSHGetService extends SSHService<SSHGetConfiguration> {
 
-import arc.xml.XmlWriter;
-import unimelb.mf.model.asset.worm.Worm;
-
-public abstract class SSHGetService extends AbstractSSHService {
-
-    protected Boolean readOnly = null;
-    protected Worm worm = null;
-    private Set<String> _srcPaths;
-    private String _dstNamespace = null;
-
-    public SSHGetService() {
-        _srcPaths = new LinkedHashSet<String>();
-    }
-
-    @Override
-    public void validateArgs() throws IllegalArgumentException {
-        super.validateArgs();
-        if (_srcPaths.isEmpty()) {
-            throw new IllegalArgumentException("Missing path on the remote SSH server.");
-        }
-        if (_dstNamespace == null) {
-            throw new IllegalArgumentException("Missing namespace path on the Mediaflux server.");
-        }
-    }
-
-    @Override
-    public void serviceArgs(XmlWriter w) throws Throwable {
-        super.serviceArgs(w);
-        if (this.readOnly != null) {
-            w.add("read-only", this.readOnly);
-        }
-        if (this.worm != null) {
-            w.push("worm");
-            this.worm.save(w);
-            w.pop();
-        }
-        if (_dstNamespace != null) {
-            w.add("namespace", _dstNamespace);
-        }
-        if (_srcPaths != null) {
-            for (String srcPath : _srcPaths) {
-                w.add("path", srcPath);
-            }
-        }
-    }
-
-    public void setReadOnly(boolean readOnly) {
-        this.readOnly = readOnly;
-    }
-
-    public void setWorm(Worm worm) {
-        this.worm = worm;
-    }
-
-    public Set<String> srcPaths() {
-        return Collections.unmodifiableSet(_srcPaths);
+    protected SSHGetService(SSHGetConfiguration cfg) {
+        super(cfg);
     }
-
-    public void setSrcPath(String path) {
-        _srcPaths.clear();
-        if (path != null) {
-            _srcPaths.add(path);
-        }
-    }
-
-    public void setSrcPaths(Collection<String> paths) {
-        _srcPaths.clear();
-        if (paths != null) {
-            _srcPaths.addAll(paths);
-        }
-    }
-
-    public void addSrcPath(String path) {
-        if (path != null) {
-            _srcPaths.add(path);
-        }
-    }
-
-    public String dstNamespace() {
-        return _dstNamespace;
-    }
-
-    public void setDstNamespace(String namespace) {
-        _dstNamespace = namespace;
-    }
-
-    public Worm worm() {
-        return this.worm;
-    }
-
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHPutConfiguration.java b/src/main/java/unimelb/mf/client/ssh/SSHPutConfiguration.java
new file mode 100644
index 0000000000000000000000000000000000000000..ba12137847dc1af136efd3af19893135419593bf
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/ssh/SSHPutConfiguration.java
@@ -0,0 +1,78 @@
+package unimelb.mf.client.ssh;
+
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import arc.xml.XmlWriter;
+
+public class SSHPutConfiguration extends SSHConfiguration {
+    private String _where;
+    private Set<String> _namespaces;
+    private Map<String, Boolean> _cids;
+    private Set<String> _assetIds;
+    private String _expr;
+    private Boolean _unarchive;
+    private String _directory;
+
+    SSHPutConfiguration(String host, int port, String username, String password, String privateKey, String passphrase,
+            String baseDir, boolean stopOnError, int retries, String where, Collection<String> namespaces,
+            Map<String, Boolean> cids, Collection<String> assetIds, String expr, Boolean unarchive, String directory) {
+        super(host, port, username, password, privateKey, passphrase, baseDir, stopOnError, retries);
+        _where = where;
+        if (namespaces != null && !namespaces.isEmpty()) {
+            _namespaces = new LinkedHashSet<String>();
+            _namespaces.addAll(namespaces);
+        }
+        if (cids != null && !cids.isEmpty()) {
+            _cids = new LinkedHashMap<String, Boolean>();
+            _cids.putAll(cids);
+        }
+        if (assetIds != null && !assetIds.isEmpty()) {
+            _assetIds = new LinkedHashSet<String>();
+            _assetIds.addAll(assetIds);
+        }
+        _expr = expr;
+        _unarchive = unarchive;
+        _directory = directory;
+    }
+
+    @Override
+    public final Action action() {
+        return Action.PUT;
+    }
+
+    @Override
+    public void save(XmlWriter w) throws Throwable {
+        super.save(w);
+        if (_cids != null && !_cids.isEmpty()) {
+            Set<String> cids = _cids.keySet();
+            for (String cid : cids) {
+                w.add("cid", new String[] { "recursive", Boolean.toString(_cids.get(cid)) }, cid);
+            }
+        }
+        if (_assetIds != null && !_assetIds.isEmpty()) {
+            for (String assetId : _assetIds) {
+                w.add("id", assetId);
+            }
+        }
+        if (_namespaces != null && !_namespaces.isEmpty()) {
+            for (String namespace : _namespaces) {
+                w.add("namespace", namespace);
+            }
+        }
+        if (_where != null) {
+            w.add("where", _where);
+        }
+        if (_expr != null) {
+            w.add("expr", _expr);
+        }
+        if (_unarchive != null) {
+            w.add("unarchive", _unarchive);
+        }
+        w.add("directory", _directory);
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHPutConfigurationBuilder.java b/src/main/java/unimelb/mf/client/ssh/SSHPutConfigurationBuilder.java
new file mode 100644
index 0000000000000000000000000000000000000000..0e713c34dace9877ae31720d7e1dcce8179a97f8
--- /dev/null
+++ b/src/main/java/unimelb/mf/client/ssh/SSHPutConfigurationBuilder.java
@@ -0,0 +1,135 @@
+package unimelb.mf.client.ssh;
+
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import unimelb.mf.client.ssh.SSHConfiguration.Action;
+
+public class SSHPutConfigurationBuilder
+        extends SSHConfigurationBuilder<SSHPutConfiguration, SSHPutConfigurationBuilder> {
+
+    protected String where;
+    protected Set<String> namespaces;
+    protected Map<String, Boolean> cids;
+    protected Set<String> assetIds;
+    protected String expr;
+    protected Boolean unarchive;
+    protected String directory;
+
+    @Override
+    public SSHPutConfiguration build() {
+        validate();
+        return new SSHPutConfiguration(this.host, this.port, this.username, this.password, this.privateKey,
+                this.passphrase, this.baseDirectory, this.stopOnError, this.retries, this.where, this.namespaces,
+                this.cids, this.assetIds, this.expr, this.unarchive, this.directory);
+    }
+
+    @Override
+    public void validate() throws IllegalArgumentException {
+        super.validate();
+        if (this.directory == null) {
+            throw new IllegalArgumentException("Missing destination directory on the remote SSH server.");
+        }
+        if (this.where == null && (this.namespaces == null || this.namespaces.isEmpty())
+                && (this.cids == null || this.cids.isEmpty()) && (this.assetIds == null || this.assetIds.isEmpty())) {
+            throw new IllegalArgumentException("Missing source asset namespaces (or selection query).");
+        }
+    }
+
+    public void setCiteableIds(boolean recursive, String... cids) {
+        if (this.cids != null) {
+            this.cids.clear();
+        }
+        if (cids != null) {
+            if (this.cids == null) {
+                this.cids = new LinkedHashMap<String, Boolean>();
+            }
+            for (String cid : cids) {
+                this.cids.put(cid, recursive);
+            }
+        }
+    }
+
+    public void addCiteableId(String cid, boolean recursive) {
+        if (cid != null) {
+            if (this.cids == null) {
+                this.cids = new LinkedHashMap<String, Boolean>();
+            }
+            this.cids.put(cid, recursive);
+        }
+    }
+
+    public void addCiteableId(String cid) {
+        addCiteableId(cid, true);
+    }
+
+    public void setAssetIds(String... assetIds) {
+        if (this.assetIds != null) {
+            this.assetIds.clear();
+        }
+        if (assetIds != null) {
+            if (this.assetIds == null) {
+                this.assetIds = new LinkedHashSet<String>();
+            }
+            for (String assetId : assetIds) {
+                this.assetIds.add(assetId);
+            }
+        }
+    }
+
+    public void addAssetId(String assetId) {
+        if (assetId != null) {
+            if (this.assetIds == null) {
+                this.assetIds = new LinkedHashSet<String>();
+            }
+            this.assetIds.add(assetId);
+        }
+    }
+
+    public void setNamespaces(String... namespaces) {
+        if (this.namespaces != null) {
+            this.namespaces.clear();
+        }
+        if (namespaces != null) {
+            if (this.namespaces == null) {
+                this.namespaces = new LinkedHashSet<String>();
+            }
+            for (String namespace : namespaces) {
+                this.namespaces.add(namespace);
+            }
+        }
+    }
+
+    public void addNamespace(String namespace) {
+        if (namespace != null) {
+            if (this.namespaces == null) {
+                this.namespaces = new LinkedHashSet<String>();
+            }
+            this.namespaces.add(namespace);
+        }
+    }
+
+    public void setWhere(String where) {
+        this.where = where;
+    }
+
+    public void setOutputPathExpression(String expr) {
+        this.expr = expr;
+    }
+
+    public void setDstDirectory(String directory) {
+        this.directory = directory;
+    }
+
+    public void setUnarchiveContents(Boolean unarchive) {
+        this.unarchive = unarchive;
+    }
+
+    @Override
+    public final Action action() {
+        return Action.PUT;
+    }
+
+}
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHPutService.java b/src/main/java/unimelb/mf/client/ssh/SSHPutService.java
index 37852ba2eb549af23db2d4dce9f5cb4f0f4a8ed4..bf624b4947f712a1083800e7d1c423c3d689b9b2 100644
--- a/src/main/java/unimelb/mf/client/ssh/SSHPutService.java
+++ b/src/main/java/unimelb/mf/client/ssh/SSHPutService.java
@@ -1,137 +1,9 @@
 package unimelb.mf.client.ssh;
 
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.Map;
-import java.util.Set;
+public abstract class SSHPutService extends SSHService<SSHPutConfiguration> {
 
-import arc.xml.XmlWriter;
-
-public abstract class SSHPutService extends AbstractSSHService {
-
-    private Map<String, Boolean> _cids;
-    private Set<String> _assetIds;
-    private Set<String> _namespaces;
-    private String _where;
-    private String _expr;
-    private Boolean _unarchive;
-
-    private String _directory;
-
-    public SSHPutService() {
-        _cids = new LinkedHashMap<String, Boolean>();
-        _assetIds = new LinkedHashSet<String>();
-        _namespaces = new LinkedHashSet<String>();
-        _where = null;
-    }
-
-    @Override
-    public void validateArgs() throws IllegalArgumentException {
-        super.validateArgs();
-        if (_cids.isEmpty() && _assetIds.isEmpty() && _namespaces.isEmpty() && _where == null) {
-            throw new IllegalArgumentException(
-                    "Missing source asset specifications, either id, cid, namespace or query must be specified.");
-        }
-    }
-
-    @Override
-    public void serviceArgs(XmlWriter w) throws Throwable {
-        super.serviceArgs(w);
-        if (!_cids.isEmpty()) {
-            Set<String> cids = _cids.keySet();
-            for (String cid : cids) {
-                w.add("cid", new String[] { "recursive", Boolean.toString(_cids.get(cid)) }, cid);
-            }
-        }
-        if (!_assetIds.isEmpty()) {
-            for (String assetId : _assetIds) {
-                w.add("id", assetId);
-            }
-        }
-        if (!_namespaces.isEmpty()) {
-            for (String namespace : _namespaces) {
-                w.add("namespace", namespace);
-            }
-        }
-        if (_where != null) {
-            w.add("where", _where);
-        }
-        if (_expr != null) {
-            w.add("expr", _expr);
-        }
-        if (_unarchive != null) {
-            w.add("unarchive", _unarchive);
-        }
-        w.add("directory", _directory);
-    }
-
-    public void setCiteableIds(boolean recursive, String... cids) {
-        _cids.clear();
-        if (cids != null) {
-            for (String cid : cids) {
-                _cids.put(cid, recursive);
-            }
-        }
-    }
-
-    public void addCiteableId(String cid, boolean recursive) {
-        if (cid != null) {
-            _cids.put(cid, recursive);
-        }
-    }
-
-    public void addCiteableId(String cid) {
-        addCiteableId(cid, true);
-    }
-
-    public void setAssetIds(String... assetIds) {
-        _assetIds.clear();
-        if (assetIds != null) {
-            for (String assetId : assetIds) {
-                _assetIds.add(assetId);
-            }
-        }
-    }
-
-    public void addAssetId(String assetId) {
-        if (assetId != null) {
-            _assetIds.add(assetId);
-        }
-    }
-
-    public void setNamespaces(String... namespaces) {
-        _namespaces.clear();
-        if (namespaces != null) {
-            for (String namespace : namespaces) {
-                _assetIds.add(namespace);
-            }
-        }
-    }
-
-    public void addNamespace(String namespace) {
-        if (namespace != null) {
-            _namespaces.add(namespace);
-        }
-    }
-
-    public void setWhere(String where) {
-        _where = where;
-    }
-
-    public void setOutputPathExpression(String expr) {
-        _expr = expr;
-    }
-
-    public void setDstDirectory(String directory) {
-        _directory = directory;
-    }
-
-    public String dstDirectory() {
-        return _directory;
-    }
-
-    public void setUnarchiveContents(Boolean unarchive) {
-        _unarchive = unarchive;
+    protected SSHPutService(SSHPutConfiguration cfg) {
+        super(cfg);
     }
 
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/SSHService.java b/src/main/java/unimelb/mf/client/ssh/SSHService.java
index 37626cf3e827ca105c90761afe521b77cc2f19d8..5eb04b03449de2c8877de8a7ce2816748c2544f3 100644
--- a/src/main/java/unimelb/mf/client/ssh/SSHService.java
+++ b/src/main/java/unimelb/mf/client/ssh/SSHService.java
@@ -1,37 +1,90 @@
 package unimelb.mf.client.ssh;
 
-import unimelb.mf.client.session.MFService;
+import java.util.List;
 
-public interface SSHService extends MFService {
+import arc.mf.client.RequestOptions;
+import arc.mf.client.ServerClient.Input;
+import arc.mf.client.ServerClient.Output;
+import arc.mf.client.ServerRoute;
+import arc.utils.AbortableOperationHandler;
+import arc.utils.CanAbort;
+import arc.xml.XmlStringWriter;
+import unimelb.mf.client.session.MFRequest;
+import unimelb.mf.client.session.MFSession;
 
-    void setSSHServerHost(String sshServerHost);
+public abstract class SSHService<T extends SSHConfiguration> implements MFRequest {
 
-    void setSSHServerPort(int sshServerPort);
+    private T _cfg;
+    private RequestOptions _ops;
+    private CanAbort _ca;
 
-    void setSSHUsername(String username);
+    protected SSHService(T cfg) {
+        _cfg = cfg;
+        _ops = new RequestOptions();
+        _ops.setAbortHandler(new AbortableOperationHandler() {
 
-    void setSSHPassword(String password);
+            @Override
+            public void finished(CanAbort ca) {
+                _ca = null;
+            }
 
-    void setSSHBaseDirectory(String baseDir);
+            @Override
+            public void started(CanAbort ca) {
+                _ca = ca;
+            }
+        });
+        _ops.setBackground(true);
+    }
+
+    public T configuration() {
+        return _cfg;
+    }
+
+    @Override
+    public ServerRoute route() {
+        return null;
+    }
 
-    void setSSHPrivateKey(String privateKey);
+    @Override
+    public String args() {
+        XmlStringWriter w = new XmlStringWriter();
+        try {
+            _cfg.save(w);
+            return w.document();
+        } catch (Throwable e) {
+            if (e instanceof RuntimeException) {
+                throw (RuntimeException) e;
+            } else {
+                throw new RuntimeException(e);
+            }
+        }
+    }
 
-    void setSSHPassphrase(String passphrase);
+    @Override
+    public List<Input> inputs() {
+        return null;
+    }
 
-    void setStopOnError(boolean stopOnError);
-    
-    boolean stopOnError();
-    
-    boolean continueOnError();
+    @Override
+    public Output output() {
+        return null;
+    }
 
-    void setRetryOnError(int retryOnError);
+    @Override
+    public RequestOptions options() {
+        return _ops;
+    }
 
-    default int retryOnError() {
-        return 0;
+    @Override
+    public void abort() throws Throwable {
+        if (_ca != null) {
+            _ca.abort();
+            _ca = null;
+        }
     }
 
-    SSHTransferProtocol sshTransferProtocol();
-    
-    SSHTransferDirection sshTransferDirection();
+    public long executeBackground(MFSession session) throws Throwable {
+        return execute(session).longValue("id");
+    }
 
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/cli/SCPGetCLI.java b/src/main/java/unimelb/mf/client/ssh/cli/SCPGetCLI.java
index 5a4a1a36dfb90e374fbb4b0ec333659e12362efb..11484724638155e8ab9e05b5eb3f7aa9e41da797 100644
--- a/src/main/java/unimelb/mf/client/ssh/cli/SCPGetCLI.java
+++ b/src/main/java/unimelb/mf/client/ssh/cli/SCPGetCLI.java
@@ -1,31 +1,32 @@
 package unimelb.mf.client.ssh.cli;
 
 import unimelb.mf.client.ssh.SCPGetService;
-import unimelb.mf.client.ssh.SSHTransferProtocol;
+import unimelb.mf.client.ssh.SSHService;
 
-public class SCPGetCLI extends SSHGetCLI<SCPGetService> {
-
-    protected SCPGetCLI() {
-        super(new SCPGetService());
-    }
+public class SCPGetCLI extends SSHGetCLI {
 
     @Override
-    protected final String appName() {
+    public final String applicationName() {
         return "scp-get";
     }
 
-    @Override
-    protected final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SCP;
-    }
-
     public static void main(String[] args) throws Throwable {
         execute(new SCPGetCLI(), args);
     }
 
     @Override
-    protected String description() {
+    public String description() {
         return "Import files from remote SSH server to Mediaflux using scp.";
     }
 
+    @Override
+    protected SSHService<?> createService(Settings settings) {
+        return new SCPGetService(settings.config().build());
+    }
+
+    @Override
+    protected final String protocol() {
+        return "SSH";
+    }
+
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/cli/SCPPutCLI.java b/src/main/java/unimelb/mf/client/ssh/cli/SCPPutCLI.java
index 4b32c983521d209b912171369dcd630c8b10a97a..e2463209a65f62bb0da55790b106f18e661fd1e6 100644
--- a/src/main/java/unimelb/mf/client/ssh/cli/SCPPutCLI.java
+++ b/src/main/java/unimelb/mf/client/ssh/cli/SCPPutCLI.java
@@ -1,31 +1,32 @@
 package unimelb.mf.client.ssh.cli;
 
 import unimelb.mf.client.ssh.SCPPutService;
-import unimelb.mf.client.ssh.SSHTransferProtocol;
+import unimelb.mf.client.ssh.SSHService;
 
-public class SCPPutCLI extends SSHPutCLI<SCPPutService> {
-
-    protected SCPPutCLI() {
-        super(new SCPPutService());
-    }
+public class SCPPutCLI extends SSHPutCLI {
 
     @Override
-    protected final String appName() {
+    public final String applicationName() {
         return "scp-put";
     }
 
-    @Override
-    protected final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SCP;
-    }
-
     public static void main(String[] args) throws Throwable {
         execute(new SCPPutCLI(), args);
     }
 
     @Override
-    protected String description() {
+    public String description() {
         return "Export Mediaflux assets to remote SSH server using scp.";
     }
 
+    @Override
+    protected final String protocol() {
+        return "SSH";
+    }
+
+    @Override
+    protected SSHService<?> createService(Settings settings) {
+        return new SCPPutService(settings.config().build());
+    }
+
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/cli/SFTPGetCLI.java b/src/main/java/unimelb/mf/client/ssh/cli/SFTPGetCLI.java
index baa03f1d7cf0d240d4815f024c2f6ba9de9b10f8..feea8a738fa62bcec67d449e319679116bc60270 100644
--- a/src/main/java/unimelb/mf/client/ssh/cli/SFTPGetCLI.java
+++ b/src/main/java/unimelb/mf/client/ssh/cli/SFTPGetCLI.java
@@ -1,30 +1,31 @@
 package unimelb.mf.client.ssh.cli;
 
 import unimelb.mf.client.ssh.SFTPGetService;
-import unimelb.mf.client.ssh.SSHTransferProtocol;
+import unimelb.mf.client.ssh.SSHService;
 
-public class SFTPGetCLI extends SSHGetCLI<SFTPGetService> {
-
-    protected SFTPGetCLI() {
-        super(new SFTPGetService());
-    }
+public class SFTPGetCLI extends SSHGetCLI {
 
     @Override
-    protected final String appName() {
+    public final String applicationName() {
         return "sftp-get";
     }
 
-    @Override
-    protected final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SFTP;
-    }
-
     public static void main(String[] args) throws Throwable {
         execute(new SFTPGetCLI(), args);
     }
 
     @Override
-    protected String description() {
+    public String description() {
         return "Import files from remote SFTP server to Mediaflux using sftp.";
     }
+
+    @Override
+    protected SSHService<?> createService(Settings settings) {
+        return new SFTPGetService(settings.config().build());
+    }
+
+    @Override
+    protected final String protocol() {
+        return "SFTP";
+    }
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/cli/SFTPPutCLI.java b/src/main/java/unimelb/mf/client/ssh/cli/SFTPPutCLI.java
index 04e51e54481ec3f510cf90e5d6328ac5565ce277..ee9493ba57421d853432cf3fe3d3a9bf2000f45d 100644
--- a/src/main/java/unimelb/mf/client/ssh/cli/SFTPPutCLI.java
+++ b/src/main/java/unimelb/mf/client/ssh/cli/SFTPPutCLI.java
@@ -1,31 +1,35 @@
 package unimelb.mf.client.ssh.cli;
 
 import unimelb.mf.client.ssh.SFTPPutService;
-import unimelb.mf.client.ssh.SSHTransferProtocol;
+import unimelb.mf.client.ssh.SSHService;
 
-public class SFTPPutCLI extends SSHPutCLI<SFTPPutService> {
+public class SFTPPutCLI extends SSHPutCLI {
 
     protected SFTPPutCLI() {
-        super(new SFTPPutService());
+        super();
     }
 
     @Override
-    protected final String appName() {
+    public final String applicationName() {
         return "sftp-put";
     }
 
     @Override
-    protected final SSHTransferProtocol sshTransferProtocol() {
-        return SSHTransferProtocol.SFTP;
+    public String description() {
+        return "Export Mediaflux assets to remote SFTP server using sftp.";
     }
 
-    public static void main(String[] args) throws Throwable {
-        execute(new SFTPPutCLI(), args);
+    @Override
+    protected SSHService<?> createService(Settings settings) {
+        return new SFTPPutService(settings.config().build());
     }
 
     @Override
-    protected String description() {
-        return "Export Mediaflux assets to remote SFTP server using sftp.";
+    protected final String protocol() {
+        return "SFTP";
     }
 
+    public static void main(String[] args) throws Throwable {
+        execute(new SFTPPutCLI(), args);
+    }
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/cli/SSHCLI.java b/src/main/java/unimelb/mf/client/ssh/cli/SSHCLI.java
index 3c89a4b2c9d97c18bef195da8e5d9ec4b684f355..28c352bf8d94016d23175e12ab097c391af6591d 100644
--- a/src/main/java/unimelb/mf/client/ssh/cli/SSHCLI.java
+++ b/src/main/java/unimelb/mf/client/ssh/cli/SSHCLI.java
@@ -8,119 +8,156 @@ import java.util.TimerTask;
 import arc.mf.client.ServerClient;
 import arc.utils.ObjectUtil;
 import arc.xml.XmlDoc;
-import unimelb.mf.client.session.MFConnectionSettings;
 import unimelb.mf.client.session.MFSession;
+import unimelb.mf.client.ssh.SSHConfigurationBuilder;
 import unimelb.mf.client.ssh.SSHService;
-import unimelb.mf.client.ssh.SSHTransferDirection;
-import unimelb.mf.client.ssh.SSHTransferProtocol;
+import unimelb.mf.client.task.AbstractMFAppCLI;
+import unimelb.mf.client.task.MFApp;
+import unimelb.mf.client.util.LoggingUtils;
 import unimelb.mf.client.util.XmlUtils;
 import unimelb.mf.model.service.BackgroundService;
 import unimelb.mf.model.task.Task;
 
-public abstract class SSHCLI<T extends SSHService> implements BackgroundService.StateListener {
+@SuppressWarnings("rawtypes")
+public abstract class SSHCLI<T extends SSHCLI.Settings> extends AbstractMFAppCLI<T>
+        implements BackgroundService.StateListener {
 
-    protected abstract String appName();
-
-    protected abstract String description();
-
-    protected abstract SSHTransferProtocol sshTransferProtocol();
-
-    protected abstract SSHTransferDirection sshTransferDirection();
-
-    protected T service;
-
-    protected MFSession session;
+    public static abstract class Settings<C extends SSHConfigurationBuilder> implements MFApp.Settings {
+        public abstract C config();
+    }
 
-    protected boolean async = false;
+    protected boolean _async = false;
 
     private Timer _timer;
 
     private String _currentActivity = null;
 
-    protected SSHCLI(T service) {
-        this.service = service;
+    protected SSHCLI(T settings) {
+        super(LoggingUtils.createConsoleLogger(), settings);
+
     }
 
-    protected void execute(String[] args) throws Throwable {
+    protected abstract SSHService<?> createService(T settings);
 
-        MFConnectionSettings mfcs = new MFConnectionSettings();
-        mfcs.findAndLoadFromConfigFile();
-        mfcs.loadFromXmlFile(Applications.PROPERTIES_FILE.toFile());
-        mfcs.setApp(Applications.APP_NAME);
-        for (int i = 0; i < args.length;) {
-            if ("--mf.config".equalsIgnoreCase(args[i])) {
-                try {
-                    mfcs.loadFromConfigFile(args[i + 1]);
-                } catch (Throwable e) {
-                    throw new IllegalArgumentException("Invalid --mf.config: " + args[i + 1], e);
-                }
+    @Override
+    protected void parseSettings(MFSession session, List<String> args) throws Throwable {
+        int n = args == null ? 0 : args.size();
+        for (int i = 0; i < n;) {
+            if (args.get(i).equals("--ssh.host")) {
+                settings().config().setHost(args.get(i + 1));
                 i += 2;
-            } else if (args[i].equals("--mf.host")) {
-                mfcs.setServerHost(args[i + 1]);
+            } else if (args.get(i).equals("--ssh.port")) {
+                settings().config().setPort(Integer.parseInt(args.get(i + 1)));
                 i += 2;
-            } else if (args[i].equals("--mf.port")) {
-                mfcs.setServerPort(Integer.parseInt(args[i + 1]));
+            } else if (args.get(i).equals("--ssh.user")) {
+                settings().config().setUsername(args.get(i + 1));
                 i += 2;
-            } else if (args[i].equals("--mf.transport")) {
-                mfcs.setServerTransport(args[i + 1]);
+            } else if (args.get(i).equals("--ssh.password")) {
+                settings().config().setPassword(args.get(i + 1));
                 i += 2;
-            } else if (args[i].equals("--mf.auth")) {
-                String auth = args[i + 1];
-                String[] parts = auth.split(",");
-                if (parts == null || parts.length != 3) {
-                    throw new IllegalArgumentException("Invalid mf.auth: " + auth);
-                }
-                mfcs.setUserCredentials(parts[0], parts[1], parts[2]);
+            } else if (args.get(i).equals("--ssh.private-key")) {
+                settings().config().setPrivateKey(args.get(i + 1));
                 i += 2;
-            } else if (args[i].equals("--mf.token")) {
-                mfcs.setToken(args[i + 1]);
+            } else if (args.get(i).equals("--ssh.passphrase")) {
+                settings().config().setPassphrase(args.get(i + 1));
                 i += 2;
-            } else if (args[i].equals("--mf.async")) {
-                async = true;
+            } else if (args.get(i).equals("--async")) {
+                _async = true;
                 i++;
-            } else if (args[i].equals("--ssh.host")) {
-                service.setSSHServerHost(args[i + 1]);
-                i += 2;
-            } else if (args[i].equals("--ssh.port")) {
-                service.setSSHServerPort(Integer.parseInt(args[i + 1]));
-                i += 2;
-            } else if (args[i].equals("--ssh.user")) {
-                service.setSSHUsername(args[i + 1]);
-                i += 2;
-            } else if (args[i].equals("--ssh.password")) {
-                service.setSSHPassword(args[i + 1]);
-                i += 2;
-            } else if (args[i].equals("--ssh.private-key")) {
-                service.setSSHPrivateKey(args[i + 1]);
-                i += 2;
-            } else if (args[i].equals("--ssh.passphrase")) {
-                service.setSSHPassphrase(args[i + 1]);
-                i += 2;
             } else {
                 i = parseArgs(args, i);
             }
         }
-        // mfcs.checkMissingArguments();
-        this.session = new MFSession(mfcs);
-        if (mfcs.hasMissingArgument()) {
-            this.session.doConsoleLogon();
-        } else {
-            this.session.testAuthentication();
+    }
+
+    protected abstract int parseArgs(List<String> args, int idx);
+
+    public void printUsage(PrintStream s) {
+        // @formatter:off
+        s.println();
+        s.println("USAGE:");
+        s.println(String.format(" %s <mediaflux-arguments> <%s-arguments>", applicationName(), protocol().toLowerCase()));
+        s.println();
+        s.println("DESCRIPTION:");
+        s.println("    " + description());
+        s.println();
+        s.println("MEDIAFLUX ARGUMENTS:");
+        super.printMFArgs(s);
+        s.println("    --async                               Executes the job in the background. The background service can be checked by executing service.background.describe service in Mediaflux Aterm.");
+        s.println();
+        s.println(String.format("%s ARGUMENTS:", protocol().toUpperCase()));
+        printSshArgs(s);
+        s.println();
+        s.println("EXAMPLES:");
+        printExamples(s);
+        // @formatter:on
+    }
+
+    protected void printSshArgs(PrintStream s) {
+        //@formatter:off
+        s.println(String.format("    --ssh.host <host>                     %s server host.", protocol().toUpperCase()));
+        s.println(String.format("    --ssh.port <port>                     %s server port. Optional. Defaults to 22.", protocol().toUpperCase()));
+        s.println(String.format("    --ssh.user <username>                 %s user name.", protocol().toUpperCase()));
+        s.println(String.format("    --ssh.password <password>             %s user's password.", protocol().toUpperCase()));
+        s.println(String.format("    --ssh.private-key <private-key>       %s user's private key.", protocol().toUpperCase()));
+        s.println(String.format("    --ssh.passphrase <passphrase>         Passphrase for the %s user's private key.", protocol().toUpperCase()));
+        //@formatter:on
+    }
+
+    protected abstract String protocol();
+
+    protected abstract void printExamples(PrintStream s);
+
+    private static String formatTime(double durationSeconds) {
+        long seconds = (long) durationSeconds;
+        long minutes = seconds / 60L;
+        seconds = seconds % 60L;
+        long hours = minutes / 60L;
+        minutes = minutes % 60L;
+        return String.format("%02d:%02d:%02d", hours, minutes, seconds);
+    }
+
+    public static void execute(SSHCLI<?> cli, String[] args) throws Throwable {
+        try {
+            cli.execute(args);
+        } catch (Throwable e) {
+            e.printStackTrace();
+            if (e instanceof IllegalArgumentException) {
+                System.err.println("Error: " + e.getMessage());
+                cli.printUsage(System.out);
+            }
+            System.exit(1);
+        }
+    }
+
+    @Override
+    public void execute(String[] args) throws Throwable {
+        if (args != null) {
+            for (String arg : args) {
+                if ("--async".equalsIgnoreCase(arg)) {
+                    _async = true;
+                    break;
+                }
+            }
         }
-        long id = this.service.executeBackground(this.session);
-        if (async) {
+        super.execute(args, _async);
+    }
+
+    @Override
+    public void execute() throws Throwable {
+        long id = createService(settings()).executeBackground(session());
+        if (_async) {
             System.out.println("Background service ID: " + id);
             System.out.println();
             System.out.println("Run \n    service.background.describe :id " + id + "\n to check its status.");
-            session.discard();
+            session().discard();
         } else {
             _timer = new Timer();
             _timer.schedule(new TimerTask() {
-
                 @Override
                 public void run() {
                     try {
-                        BackgroundService.describe(session, id, SSHCLI.this);
+                        BackgroundService.describe(session(), id, SSHCLI.this);
                     } catch (Throwable e) {
                         e.printStackTrace();
                     }
@@ -129,6 +166,7 @@ public abstract class SSHCLI<T extends SSHService> implements BackgroundService.
         }
     }
 
+    @Override
     public void updated(BackgroundService bs) throws Throwable {
         if (bs.finished()) {
             _timer.cancel();
@@ -154,11 +192,11 @@ public abstract class SSHCLI<T extends SSHService> implements BackgroundService.
             System.out.println(bs.state());
         }
 
-        if (bs.state() == Task.State.COMPLETED && this.service.continueOnError()) {
+        if (bs.state() == Task.State.COMPLETED) {
             /*
              * completed
              */
-            XmlDoc.Element re = bs.getResult(this.session);
+            XmlDoc.Element re = bs.getResult(session());
             List<XmlDoc.Element> fes = re.elements();
             if (fes != null) {
                 for (XmlDoc.Element fe : fes) {
@@ -168,7 +206,7 @@ public abstract class SSHCLI<T extends SSHService> implements BackgroundService.
         }
 
         if (bs.finished()) {
-            session.discard();
+            session().discard();
         }
         if (bs.failed()) {
             /*
@@ -187,73 +225,4 @@ public abstract class SSHCLI<T extends SSHService> implements BackgroundService.
         }
     }
 
-    protected abstract int parseArgs(String[] args, int idx);
-
-    public void printUsage(PrintStream s) {
-        s.println();
-        s.println("USAGE:");
-        s.println(String.format("    %s <mediaflux-arguments> <%s-arguments>", appName(),
-                sshTransferProtocol().toString()));
-        s.println();
-        s.println("DESCRIPTION:");
-        s.println("    " + description());
-        s.println();
-        s.println("MEDIAFLUX ARGUMENTS:");
-        printMediafluxArgs(s);
-        s.println();
-        s.println(String.format("%s ARGUMENTS:", sshTransferProtocol().toString().toUpperCase()));
-        printSshArgs(s);
-        s.println();
-        s.println("EXAMPLES:");
-        printExamples(s);
-    }
-
-    protected void printMediafluxArgs(PrintStream s) {
-        //@formatter:off
-        s.println("    --mf.config <mflux.cfg>               Path to the config file that contains Mediaflux server details and user credentials.");
-        s.println("    --mf.host <host>                      Mediaflux server host.");
-        s.println("    --mf.port <port>                      Mediaflux server port.");
-        s.println("    --mf.transport <https|http|tcp/ip>    Mediaflux server transport, can be http, https or tcp/ip.");
-        s.println("    --mf.auth <domain,user,password>      Mediaflux user credentials.");
-        s.println("    --mf.token <token>                    Mediaflux secure identity token.");
-        s.println("    --mf.async                            Executes the job in the background. The background service can be checked by executing service.background.describe service in Mediaflux Aterm.");
-        //@formatter:on
-    }
-
-    protected void printSshArgs(PrintStream s) {
-        String protocol = sshTransferProtocol() == SSHTransferProtocol.SFTP ? "SFTP" : "SSH";
-        //@formatter:off
-        s.println(String.format("    --ssh.host <host>                     %s server host.", protocol));
-        s.println(String.format("    --ssh.port <port>                     %s server port. Optional. Defaults to 22.", protocol));
-        s.println(String.format("    --ssh.user <username>                 %s user name.", protocol));
-        s.println(String.format("    --ssh.password <password>             %s user's password.", protocol));
-        s.println(String.format("    --ssh.private-key <private-key>       %s user's private key.", protocol));
-        s.println(String.format("    --ssh.passphrase <passphrase>         Passphrase for the %s user's private key.", protocol));
-        //@formatter:on
-    }
-
-    protected abstract void printExamples(PrintStream s);
-
-    private static String formatTime(double durationSeconds) {
-        long seconds = (long) durationSeconds;
-        long minutes = seconds / 60L;
-        seconds = seconds % 60L;
-        long hours = minutes / 60L;
-        minutes = minutes % 60L;
-        return String.format("%02d:%02d:%02d", hours, minutes, seconds);
-    }
-
-    public static <T extends SSHService> void execute(SSHCLI<T> cli, String[] args) throws Throwable {
-        try {
-            cli.execute(args);
-        } catch (Throwable e) {
-            e.printStackTrace();
-            if (e instanceof IllegalArgumentException) {
-                System.err.println("Error: " + e.getMessage());
-                cli.printUsage(System.out);
-            }
-            System.exit(1);
-        }
-    }
-
 }
diff --git a/src/main/java/unimelb/mf/client/ssh/cli/SSHGetCLI.java b/src/main/java/unimelb/mf/client/ssh/cli/SSHGetCLI.java
index a3c76091392c7bec27c72cbd6b8f512f3163602c..f87f09cf11f7bf76b884544635c6348d4c034b2f 100644
--- a/src/main/java/unimelb/mf/client/ssh/cli/SSHGetCLI.java
+++ b/src/main/java/unimelb/mf/client/ssh/cli/SSHGetCLI.java
@@ -3,55 +3,65 @@ package unimelb.mf.client.ssh.cli;
 import java.io.PrintStream;
 import java.text.ParseException;
 import java.text.SimpleDateFormat;
+import java.util.List;
 
-import unimelb.mf.client.ssh.SSHGetService;
-import unimelb.mf.client.ssh.SSHTransferDirection;
-import unimelb.mf.client.ssh.SSHTransferProtocol;
+import unimelb.mf.client.ssh.SSHGetConfigurationBuilder;
 import unimelb.mf.model.asset.worm.Worm;
 
-public abstract class SSHGetCLI<T extends SSHGetService> extends SSHCLI<T> {
+public abstract class SSHGetCLI extends SSHCLI<SSHGetCLI.Settings> {
 
-    protected SSHGetCLI(T service) {
-        super(service);
+    protected SSHGetCLI() {
+        super(new SSHGetCLI.Settings());
     }
 
-    @Override
-    protected final SSHTransferDirection sshTransferDirection() {
-        return SSHTransferDirection.GET;
+    public static class Settings extends SSHCLI.Settings<SSHGetConfigurationBuilder> {
+
+        private SSHGetConfigurationBuilder _config;
+
+        public Settings() {
+            _config = new SSHGetConfigurationBuilder();
+        }
+
+        @Override
+        public SSHGetConfigurationBuilder config() {
+            return _config;
+        }
+
     }
 
-    protected int parseArgs(String[] args, int i) {
-        if (args[i].equalsIgnoreCase("--mf.namespace")) {
-            this.service.setDstNamespace(args[i + 1]);
+    @Override
+    protected int parseArgs(List<String> args, int i) {
+        if (args.get(i).equalsIgnoreCase("--mf.namespace")) {
+            settings().config().setDstNamespace(args.get(i + 1));
             return i + 2;
-        } else if (args[i].equalsIgnoreCase("--mf.readonly")) {
-            this.service.setReadOnly(true);
+        } else if (args.get(i).equalsIgnoreCase("--mf.readonly")) {
+            settings().config().setReadOnly(true);
             return i + 1;
-        } else if (args[i].equalsIgnoreCase("--mf.worm")) {
-            if (this.service.worm() == null) {
-                this.service.setWorm(new Worm());
+        } else if (args.get(i).equalsIgnoreCase("--mf.worm")) {
+            if (settings().config().worm() == null) {
+                settings().config().setWorm(new Worm());
             }
             return i + 1;
-        } else if (args[i].equalsIgnoreCase("--mf.worm.expiry")) {
-            if (this.service.worm() == null) {
-                this.service.setWorm(new Worm());
+        } else if (args.get(i).equalsIgnoreCase("--mf.worm.expiry")) {
+            if (settings().config().worm() == null) {
+                settings().config().setWorm(new Worm());
             }
             try {
-                this.service.worm().setExpiry(new SimpleDateFormat("d-MMM-yyyy").parse(args[i + 1]));
+                settings().config().worm().setExpiry(new SimpleDateFormat("d-MMM-yyyy").parse(args.get(i + 1)));
             } catch (ParseException pe) {
-                throw new IllegalArgumentException("Invalid date value for --mf.worm.expiry: " + args[i + 1], pe);
+                throw new IllegalArgumentException("Invalid date value for --mf.worm.expiry: " + args.get(i + 1), pe);
             }
             return i + 2;
-        } else if (args[i].equalsIgnoreCase("--ssh.path")) {
-            this.service.addSrcPath(args[i + 1]);
+        } else if (args.get(i).equalsIgnoreCase("--ssh.path")) {
+            settings().config().addSrcPath(args.get(i + 1));
             return i + 2;
         } else {
-            throw new IllegalArgumentException("Unexpected argument: " + args[i]);
+            throw new IllegalArgumentException("Unexpected argument: " + args.get(i));
         }
     }
 
-    protected void printMediafluxArgs(PrintStream s) {
-        super.printMediafluxArgs(s);
+    @Override
+    protected void printMFArgs(PrintStream s) {
         //@formatter:off
         s.println("    --mf.namespace <dst-namespace>        Destination namespace on Mediaflux.");
         s.println("    --mf.readonly                         Set the assets to be read-only.");
@@ -60,20 +70,19 @@ public abstract class SSHGetCLI<T extends SSHGetService> extends SSHCLI<T> {
         //@formatter:on
     }
 
+    @Override
     protected void printSshArgs(PrintStream s) {
         super.printSshArgs(s);
-        String protocol = sshTransferProtocol() == SSHTransferProtocol.SFTP ? "SFTP" : "SSH";
         //@formatter:off
-        s.println(String.format("    --ssh.path <src-path>                 Source path on remote %s server.", protocol));
+        s.println(String.format("    --ssh.path <src-path>                 Source path on remote %s server.", protocol().toUpperCase()));
         //@formatter:on
     }
 
     @Override
     protected void printExamples(PrintStream s) {
         //@formatter:off
-        s.println(String.format("    The command below imports files from %s server into the specified Mediaflux asset namespace:", sshTransferProtocol()));
-        s.println(String.format("         %s --mf.host mediaflux.your-domain.org --mf.port 443 --mf.transport 443 --mf.auth mf_domain,mf_user,MF_PASSWD --mf.namespace /path/to/dst-namespace --ssh.host ssh-server.your-domain.org --ssh.port 22 --ssh.user ssh_username --ssh.password SSH_PASSWD --ssh.path path/to/src-directory", appName()));
-        
+        s.println(String.format("    The command below imports files from %s server into the specified Mediaflux asset namespace:", protocol().toUpperCase()));
+        s.println(String.format("         %s --mf.host mediaflux.your-domain.org --mf.port 443 --mf.transport 443 --mf.auth mf_domain,mf_user,MF_PASSWD --mf.namespace /path/to/dst-namespace --ssh.host ssh-server.your-domain.org --ssh.port 22 --ssh.user ssh_username --ssh.password SSH_PASSWD --ssh.path path/to/src-directory", applicationName()));
         s.println();
         //@formatter:on
     }
diff --git a/src/main/java/unimelb/mf/client/ssh/cli/SSHPutCLI.java b/src/main/java/unimelb/mf/client/ssh/cli/SSHPutCLI.java
index 19fbf9b34dd24aac31f29feea117907bf32c96ab..5163a39639e8243e359294c1dd914d463e52ab65 100644
--- a/src/main/java/unimelb/mf/client/ssh/cli/SSHPutCLI.java
+++ b/src/main/java/unimelb/mf/client/ssh/cli/SSHPutCLI.java
@@ -1,59 +1,69 @@
 package unimelb.mf.client.ssh.cli;
 
 import java.io.PrintStream;
+import java.util.List;
 
-import unimelb.mf.client.ssh.SSHPutService;
-import unimelb.mf.client.ssh.SSHTransferDirection;
-import unimelb.mf.client.ssh.SSHTransferProtocol;
+import unimelb.mf.client.ssh.SSHPutConfigurationBuilder;
 
-public abstract class SSHPutCLI<T extends SSHPutService> extends SSHCLI<T> {
+public abstract class SSHPutCLI extends SSHCLI<SSHPutCLI.Settings> {
 
-    protected SSHPutCLI(T service) {
-        super(service);
+    protected SSHPutCLI() {
+        super(new SSHPutCLI.Settings());
     }
 
-    @Override
-    protected final SSHTransferDirection sshTransferDirection() {
-        return SSHTransferDirection.PUT;
+    public static class Settings extends SSHCLI.Settings<SSHPutConfigurationBuilder> {
+
+        private SSHPutConfigurationBuilder _config;
+
+        public Settings() {
+            _config = new SSHPutConfigurationBuilder();
+        }
+
+        @Override
+        public SSHPutConfigurationBuilder config() {
+            return _config;
+        }
+
     }
 
-    protected int parseArgs(String[] args, int i) {
-        if (args[i].equalsIgnoreCase("--mf.namespace")) {
-            this.service.addNamespace(args[i + 1]);
+    @Override
+    protected int parseArgs(List<String> args, int i) {
+        if (args.get(i).equalsIgnoreCase("--mf.namespace")) {
+            settings().config().addNamespace(args.get(i + 1));
             return i + 2;
-        } else if (args[i].equalsIgnoreCase("--mf.unarchive")) {
-            this.service.setUnarchiveContents(true);
+        } else if (args.get(i).equalsIgnoreCase("--mf.unarchive")) {
+            settings().config().setUnarchiveContents(true);
             return i + 1;
-        } else if (args[i].equalsIgnoreCase("--ssh.directory")) {
-            this.service.setDstDirectory(args[i + 1]);
+        } else if (args.get(i).equalsIgnoreCase("--ssh.directory")) {
+            settings().config().setDstDirectory(args.get(i + 1));
             return i + 2;
         } else {
-            throw new IllegalArgumentException("Unexpected argument: " + args[i]);
+            throw new IllegalArgumentException("Unexpected argument: " + args.get(i));
         }
     }
 
-    protected void printMediafluxArgs(PrintStream s) {
-        super.printMediafluxArgs(s);
+    @Override
+    protected void printMFArgs(PrintStream s) {
+        super.printMFArgs(s);
         //@formatter:off
         s.println("    --mf.namespace <src-namespace>        Source namespace on Mediaflux.");
         s.println("    --mf.unarchive                        Unpack asset contents.");
         //@formatter:on
     }
 
+    @Override
     protected void printSshArgs(PrintStream s) {
         super.printSshArgs(s);
-        String protocol = sshTransferProtocol() == SSHTransferProtocol.SFTP ? "SFTP" : "SSH";
         //@formatter:off
-        s.println(String.format("    --ssh.directory <dst-directory>       Destination directory on remote %s server.", protocol));
+        s.println(String.format("    --ssh.directory <dst-directory>       Destination directory on remote %s server.", protocol().toUpperCase()));
         //@formatter:on
     }
 
     @Override
     protected void printExamples(PrintStream s) {
         //@formatter:off
-        s.println(String.format("    The command below exports assets from the specified Mediaflux asset namespace to remote %s server:", sshTransferProtocol()));
-        s.println(String.format("        %s --mf.host mediaflux.your-domain.org --mf.port 443 --mf.transport 443 --mf.auth mf_domain,mf_user,MF_PASSWD --mf.namespace /path/to/src-namespace --ssh.host ssh-server.your-domain.org --ssh.port 22 --ssh.user ssh_username --ssh.password SSH_PASSWD --ssh.directory path/to/dst-directory", appName()));
-        
+        s.println(String.format("    The command below exports assets from the specified Mediaflux asset namespace to remote %s server:", protocol().toUpperCase()));
+        s.println(String.format("        %s --mf.host mediaflux.your-domain.org --mf.port 443 --mf.transport 443 --mf.auth mf_domain,mf_user,MF_PASSWD --mf.namespace /path/to/src-namespace --ssh.host ssh-server.your-domain.org --ssh.port 22 --ssh.user ssh_username --ssh.password SSH_PASSWD --ssh.directory path/to/dst-directory", applicationName()));
         s.println();
         //@formatter:on
     }
diff --git a/src/main/java/unimelb/mf/client/sync/check/DefaultCheckHandler.java b/src/main/java/unimelb/mf/client/sync/check/DefaultCheckHandler.java
index a65da82d81859a480f0dfc81edda966028cbe60a..c4f21932c2aed4afc4d5d220840b40e09f3bd946 100644
--- a/src/main/java/unimelb/mf/client/sync/check/DefaultCheckHandler.java
+++ b/src/main/java/unimelb/mf/client/sync/check/DefaultCheckHandler.java
@@ -34,11 +34,14 @@ public class DefaultCheckHandler implements CheckHandler {
 
     private Path _outputFile;
 
-    public DefaultCheckHandler(Path outputFile) throws Throwable {
-        this(outputFile.getParent(), outputFile.getFileName().toString());
+    private boolean _detailedOutput = false;;
+
+    public DefaultCheckHandler(Path outputFile, boolean detailedOutput) throws Throwable {
+        this(outputFile.getParent(), outputFile.getFileName().toString(), detailedOutput);
     }
 
-    public DefaultCheckHandler(Path outputDir, String fileName) throws Throwable {
+    public DefaultCheckHandler(Path outputDir, String fileName, boolean detailedOutput) throws Throwable {
+        _detailedOutput = detailedOutput;
         if (!Files.exists(outputDir)) {
             throw new IllegalArgumentException("Directory: '" + outputDir.toString() + "' does not exist.",
                     new FileNotFoundException(outputDir.toString()));
@@ -64,11 +67,6 @@ public class DefaultCheckHandler implements CheckHandler {
         _outputFile = Paths.get(outputDir.toString(), fileName);
     }
 
-    public DefaultCheckHandler(String outputCsvPath) throws Throwable {
-        this(Paths.get(PathUtils.getParentPath(PathUtils.toSystemIndependent(outputCsvPath))),
-                PathUtils.getFileName(PathUtils.toSystemIndependent(outputCsvPath)));
-    }
-
     public void writeSummary() {
         _csvLogger.info(",,,,");
         _csvLogger.info(",,,,");
@@ -118,6 +116,9 @@ public class DefaultCheckHandler implements CheckHandler {
             _nbFileChecked.getAndIncrement();
             if (result.match()) {
                 _nbFilePassed.getAndIncrement();
+                if (_detailedOutput) {
+                    result.log(_csvLogger);
+                }
             } else {
                 _nbFileFailed.getAndIncrement();
                 if (!result.exists()) {
diff --git a/src/main/java/unimelb/mf/client/sync/cli/MFCheck.java b/src/main/java/unimelb/mf/client/sync/cli/MFCheck.java
index eb33b89b20f72fef4c606e5cb01964f50d4c0923..94bbc59fd40cc7a33b6301f76321d6f253225330 100644
--- a/src/main/java/unimelb/mf/client/sync/cli/MFCheck.java
+++ b/src/main/java/unimelb/mf/client/sync/cli/MFCheck.java
@@ -1,5 +1,6 @@
 package unimelb.mf.client.sync.cli;
 
+import java.io.File;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -11,7 +12,7 @@ import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import unimelb.mf.client.session.MFConnectionSettings;
+import unimelb.mf.client.session.MFConfigurationBuilder;
 import unimelb.mf.client.session.MFSession;
 import unimelb.mf.client.sync.MFSyncApp;
 import unimelb.mf.client.sync.check.DefaultCheckHandler;
@@ -24,10 +25,11 @@ public class MFCheck extends MFSyncApp {
 
     public static final String PROG = "unimelb-mf-check";
 
-    private MFConnectionSettings _connectionSettings;
+    private MFConfigurationBuilder _mfconfig;
     private Map<Path, String> _dirNamespaces;
     private Action.Direction _direction;
     private Path _outputFile;
+    private boolean _detailedOutput = false;
     private DefaultCheckHandler _checkHandler;
 
     public MFCheck() {
@@ -86,10 +88,10 @@ public class MFCheck extends MFSyncApp {
     }
 
     protected void parseArgs(String[] args) throws Throwable {
-        if (_connectionSettings == null) {
-            _connectionSettings = new MFConnectionSettings();
-            _connectionSettings.findAndLoadFromConfigFile();
-            _connectionSettings.setApp(applicationName());
+        if (_mfconfig == null) {
+            _mfconfig = new MFConfigurationBuilder().setConsoleLogon(true);
+            _mfconfig.findAndLoadFromConfigFile();
+            _mfconfig.setApp(applicationName());
         }
         if (args != null) {
             try {
@@ -140,12 +142,7 @@ public class MFCheck extends MFSyncApp {
         /*
          * test MF authentication
          */
-        MFSession session = new MFSession(_connectionSettings);
-        if (_connectionSettings.hasMissingArgument()) {
-            session.doConsoleLogon();
-        } else {
-            session.testAuthentication();
-        }
+        MFSession session = MFSession.create(_mfconfig);
 
         /*
          * set MF session
@@ -165,26 +162,26 @@ public class MFCheck extends MFSyncApp {
             settings().addJob(job);
         }
 
-        _checkHandler = new DefaultCheckHandler(_outputFile);
+        _checkHandler = new DefaultCheckHandler(_outputFile, _detailedOutput);
         settings().setCheckHandler(_checkHandler);
     }
 
     protected int parseMFOptions(String[] args, int i) throws Throwable {
         if ("--mf.config".equalsIgnoreCase(args[i])) {
             try {
-                _connectionSettings.loadFromConfigFile(args[i + 1]);
+                _mfconfig.loadFromConfigFile(args[i + 1]);
             } catch (Throwable e) {
                 throw new IllegalArgumentException("Invalid --mf.config: " + args[i + 1], e);
             }
             return 2;
         } else if ("--mf.host".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerHost(args[i + 1]);
+            _mfconfig.setHost(args[i + 1]);
             return 2;
         } else if ("--mf.port".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerPort(Integer.parseInt(args[i + 1]));
+            _mfconfig.setPort(Integer.parseInt(args[i + 1]));
             return 2;
         } else if ("--mf.transport".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerTransport(args[i + 1]);
+            _mfconfig.setTransport(args[i + 1]);
             return 2;
         } else if ("--mf.auth".equalsIgnoreCase(args[i])) {
             String auth = args[i + 1];
@@ -192,10 +189,10 @@ public class MFCheck extends MFSyncApp {
             if (parts == null || parts.length != 3) {
                 throw new IllegalArgumentException("Invalid mf.auth: " + auth);
             }
-            _connectionSettings.setUserCredentials(parts[0], parts[1], parts[2]);
+            _mfconfig.setUserCredentials(parts[0], parts[1], parts[2]);
             return 2;
         } else if ("--mf.token".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setToken(args[i + 1]);
+            _mfconfig.setToken(args[i + 1]);
             return 2;
         } else {
             return 0;
@@ -211,12 +208,19 @@ public class MFCheck extends MFSyncApp {
             return 2;
         } else if ("--output".equalsIgnoreCase(args[i]) || "-o".equalsIgnoreCase(args[i])) {
             _outputFile = Paths.get(args[i + 1]);
-            Path dir = _outputFile.toAbsolutePath().getParent();
-            if (!Files.exists(dir)) {
-                Files.createDirectories(dir);
+            if (args[i + 1].indexOf('/') < 0 || args[i + 1].indexOf(File.separatorChar) < 0) {
+                _outputFile = Paths.get(System.getProperty("user.dir"), args[i + 1]);
+            } else {
+                Path dir = _outputFile.toAbsolutePath().getParent();
+                if (!Files.exists(dir)) {
+                    Files.createDirectories(dir);
+                }
             }
             _outputFile = renameOutputCsvFile(_outputFile);
             return 2;
+        } else if ("--detailed-output".equalsIgnoreCase(args[i])) {
+            _detailedOutput = true;
+            return 1;
         } else if ("--no-csum-check".equalsIgnoreCase(args[i])) {
             settings().setCsumCheck(false);
             return 1;
diff --git a/src/main/java/unimelb/mf/client/sync/cli/MFDownload.java b/src/main/java/unimelb/mf/client/sync/cli/MFDownload.java
index 3c84fe7a967bcf6865cf74b0ecc63747a22b39e9..9783ce1e03d7dd8b9a26bfd210e5e358962571d7 100644
--- a/src/main/java/unimelb/mf/client/sync/cli/MFDownload.java
+++ b/src/main/java/unimelb/mf/client/sync/cli/MFDownload.java
@@ -8,7 +8,7 @@ import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import unimelb.mf.client.session.MFConnectionSettings;
+import unimelb.mf.client.session.MFConfigurationBuilder;
 import unimelb.mf.client.session.MFSession;
 import unimelb.mf.client.sync.MFSyncApp;
 import unimelb.mf.client.sync.settings.Action;
@@ -21,7 +21,7 @@ public class MFDownload extends MFSyncApp {
 
     public static final String PROG = "unimelb-mf-download";
 
-    private MFConnectionSettings _connectionSettings;
+    private MFConfigurationBuilder _mfconfig;
     private Set<String> _namespaces;
     private Path _rootDir;
 
@@ -89,10 +89,10 @@ public class MFDownload extends MFSyncApp {
     }
 
     protected void parseArgs(String[] args) throws Throwable {
-        if (_connectionSettings == null) {
-            _connectionSettings = new MFConnectionSettings();
-            _connectionSettings.findAndLoadFromConfigFile();
-            _connectionSettings.setApp(applicationName());
+        if (_mfconfig == null) {
+            _mfconfig = new MFConfigurationBuilder().setConsoleLogon(true);
+            _mfconfig.findAndLoadFromConfigFile();
+            _mfconfig.setApp(applicationName());
         }
         if (args != null) {
             try {
@@ -129,12 +129,7 @@ public class MFDownload extends MFSyncApp {
         /*
          * test MF authentication
          */
-        MFSession session = new MFSession(_connectionSettings);
-        if (_connectionSettings.hasMissingArgument()) {
-            session.doConsoleLogon();
-        } else {
-            session.testAuthentication();
-        }
+        MFSession session = MFSession.create(_mfconfig);
 
         /*
          * set MF session
@@ -155,19 +150,19 @@ public class MFDownload extends MFSyncApp {
     protected int parseMFOptions(String[] args, int i) throws Throwable {
         if ("--mf.config".equalsIgnoreCase(args[i])) {
             try {
-                _connectionSettings.loadFromConfigFile(args[i + 1]);
+                _mfconfig.loadFromConfigFile(args[i + 1]);
             } catch (Throwable e) {
                 throw new IllegalArgumentException("Invalid --mf.config: " + args[i + 1], e);
             }
             return 2;
         } else if ("--mf.host".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerHost(args[i + 1]);
+            _mfconfig.setHost(args[i + 1]);
             return 2;
         } else if ("--mf.port".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerPort(Integer.parseInt(args[i + 1]));
+            _mfconfig.setPort(Integer.parseInt(args[i + 1]));
             return 2;
         } else if ("--mf.transport".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerTransport(args[i + 1]);
+            _mfconfig.setTransport(args[i + 1]);
             return 2;
         } else if ("--mf.auth".equalsIgnoreCase(args[i])) {
             String auth = args[i + 1];
@@ -175,10 +170,10 @@ public class MFDownload extends MFSyncApp {
             if (parts == null || parts.length != 3) {
                 throw new IllegalArgumentException("Invalid mf.auth: " + auth);
             }
-            _connectionSettings.setUserCredentials(parts[0], parts[1], parts[2]);
+            _mfconfig.setUserCredentials(parts[0], parts[1], parts[2]);
             return 2;
         } else if ("--mf.token".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setToken(args[i + 1]);
+            _mfconfig.setToken(args[i + 1]);
             return 2;
         } else {
             return 0;
diff --git a/src/main/java/unimelb/mf/client/sync/cli/MFSync.java b/src/main/java/unimelb/mf/client/sync/cli/MFSync.java
index fe9730750f342fdd4fc9b047da423b4f61f91b3b..dc240ae392b5ee10a9ea62380d0398c06e9c9987 100644
--- a/src/main/java/unimelb/mf/client/sync/cli/MFSync.java
+++ b/src/main/java/unimelb/mf/client/sync/cli/MFSync.java
@@ -10,7 +10,7 @@ import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import unimelb.mf.client.session.MFConnectionSettings;
+import unimelb.mf.client.session.MFConfigurationBuilder;
 import unimelb.mf.client.session.MFSession;
 import unimelb.mf.client.sync.MFSyncApp;
 import unimelb.mf.client.sync.settings.Action;
@@ -23,7 +23,7 @@ public class MFSync extends MFSyncApp {
 
     public static final String PROG = "unimelb-mf-sync";
 
-    private MFConnectionSettings _connectionSettings;
+    private MFConfigurationBuilder _mfconfig;
     private Map<Path, String> _dirNss;
     private Map<String, Path> _nsDirs;
 
@@ -86,10 +86,10 @@ public class MFSync extends MFSyncApp {
     }
 
     protected void parseArgs(String[] args) throws Throwable {
-        if (_connectionSettings == null) {
-            _connectionSettings = new MFConnectionSettings();
-            _connectionSettings.findAndLoadFromConfigFile();
-            _connectionSettings.setApp(applicationName());
+        if (_mfconfig == null) {
+            _mfconfig = new MFConfigurationBuilder().setConsoleLogon(true);
+            _mfconfig.findAndLoadFromConfigFile();
+            _mfconfig.setApp(applicationName());
         }
         if (args != null) {
             try {
@@ -132,12 +132,7 @@ public class MFSync extends MFSyncApp {
         /*
          * test MF authentication
          */
-        MFSession session = new MFSession(_connectionSettings);
-        if (_connectionSettings.hasMissingArgument()) {
-            session.doConsoleLogon();
-        } else {
-            session.testAuthentication();
-        }
+        MFSession session = MFSession.create(_mfconfig);
 
         /*
          * set MF session
@@ -167,19 +162,19 @@ public class MFSync extends MFSyncApp {
     protected int parseMFOptions(String[] args, int i) throws Throwable {
         if ("--mf.config".equalsIgnoreCase(args[i])) {
             try {
-                _connectionSettings.loadFromConfigFile(args[i + 1]);
+                _mfconfig.loadFromConfigFile(args[i + 1]);
             } catch (Throwable e) {
                 throw new IllegalArgumentException("Invalid --mf.config: " + args[i + 1], e);
             }
             return 2;
         } else if ("--mf.host".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerHost(args[i + 1]);
+            _mfconfig.setHost(args[i + 1]);
             return 2;
         } else if ("--mf.port".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerPort(Integer.parseInt(args[i + 1]));
+            _mfconfig.setPort(Integer.parseInt(args[i + 1]));
             return 2;
         } else if ("--mf.transport".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerTransport(args[i + 1]);
+            _mfconfig.setTransport(args[i + 1]);
             return 2;
         } else if ("--mf.auth".equalsIgnoreCase(args[i])) {
             String auth = args[i + 1];
@@ -187,10 +182,10 @@ public class MFSync extends MFSyncApp {
             if (parts == null || parts.length != 3) {
                 throw new IllegalArgumentException("Invalid mf.auth: " + auth);
             }
-            _connectionSettings.setUserCredentials(parts[0], parts[1], parts[2]);
+            _mfconfig.setUserCredentials(parts[0], parts[1], parts[2]);
             return 2;
         } else if ("--mf.token".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setToken(args[i + 1]);
+            _mfconfig.setToken(args[i + 1]);
             return 2;
         } else {
             return 0;
diff --git a/src/main/java/unimelb/mf/client/sync/cli/MFUpload.java b/src/main/java/unimelb/mf/client/sync/cli/MFUpload.java
index 6cdd47d12a9225cfa5c4ee2d06dfc64b4a2127c9..f17ddfb1b95620e125b44bf295c1f446bf5396fa 100644
--- a/src/main/java/unimelb/mf/client/sync/cli/MFUpload.java
+++ b/src/main/java/unimelb/mf/client/sync/cli/MFUpload.java
@@ -9,7 +9,7 @@ import java.util.Set;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 
-import unimelb.mf.client.session.MFConnectionSettings;
+import unimelb.mf.client.session.MFConfigurationBuilder;
 import unimelb.mf.client.session.MFSession;
 import unimelb.mf.client.sync.MFSyncApp;
 import unimelb.mf.client.sync.settings.Action;
@@ -21,7 +21,7 @@ public class MFUpload extends MFSyncApp {
 
     public static final String PROG = "unimelb-mf-upload";
 
-    private MFConnectionSettings _connectionSettings;
+    private MFConfigurationBuilder _mfconfig;
     private Set<Path> _dirs;
     private String _rootNS;
 
@@ -86,10 +86,10 @@ public class MFUpload extends MFSyncApp {
     }
 
     protected void parseArgs(String[] args) throws Throwable {
-        if (_connectionSettings == null) {
-            _connectionSettings = new MFConnectionSettings();
-            _connectionSettings.findAndLoadFromConfigFile();
-            _connectionSettings.setApp(applicationName());
+        if (_mfconfig == null) {
+            _mfconfig = new MFConfigurationBuilder().setConsoleLogon(true);
+            _mfconfig.findAndLoadFromConfigFile();
+            _mfconfig.setApp(applicationName());
         }
         if (args != null) {
             try {
@@ -133,12 +133,7 @@ public class MFUpload extends MFSyncApp {
         /*
          * test MF authentication
          */
-        MFSession session = new MFSession(_connectionSettings);
-        if (_connectionSettings.hasMissingArgument()) {
-            session.doConsoleLogon();
-        } else {
-            session.testAuthentication();
-        }
+        MFSession session = MFSession.create(_mfconfig);
 
         /*
          * set MF session
@@ -159,19 +154,19 @@ public class MFUpload extends MFSyncApp {
     protected int parseMFOptions(String[] args, int i) throws Throwable {
         if ("--mf.config".equalsIgnoreCase(args[i])) {
             try {
-                _connectionSettings.loadFromConfigFile(args[i + 1]);
+                _mfconfig.loadFromConfigFile(args[i + 1]);
             } catch (Throwable e) {
                 throw new IllegalArgumentException("Invalid --mf.config: " + args[i + 1], e);
             }
             return 2;
         } else if ("--mf.host".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerHost(args[i + 1]);
+            _mfconfig.setHost(args[i + 1]);
             return 2;
         } else if ("--mf.port".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerPort(Integer.parseInt(args[i + 1]));
+            _mfconfig.setPort(Integer.parseInt(args[i + 1]));
             return 2;
         } else if ("--mf.transport".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setServerTransport(args[i + 1]);
+            _mfconfig.setTransport(args[i + 1]);
             return 2;
         } else if ("--mf.auth".equalsIgnoreCase(args[i])) {
             String auth = args[i + 1];
@@ -179,10 +174,10 @@ public class MFUpload extends MFSyncApp {
             if (parts == null || parts.length != 3) {
                 throw new IllegalArgumentException("Invalid mf.auth: " + auth);
             }
-            _connectionSettings.setUserCredentials(parts[0], parts[1], parts[2]);
+            _mfconfig.setUserCredentials(parts[0], parts[1], parts[2]);
             return 2;
         } else if ("--mf.token".equalsIgnoreCase(args[i])) {
-            _connectionSettings.setToken(args[i + 1]);
+            _mfconfig.setToken(args[i + 1]);
             return 2;
         } else {
             return 0;
diff --git a/src/main/java/unimelb/mf/client/sync/task/AssetDownloadTask.java b/src/main/java/unimelb/mf/client/sync/task/AssetDownloadTask.java
index ae7db81c127985c59c43c200411571ab3d4d8dff..8418b0aa985cbd2ccea25e2f5849992d850a53a4 100644
--- a/src/main/java/unimelb/mf/client/sync/task/AssetDownloadTask.java
+++ b/src/main/java/unimelb/mf/client/sync/task/AssetDownloadTask.java
@@ -7,7 +7,6 @@ import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.List;
 import java.util.logging.Logger;
 
 import arc.archive.ArchiveInput;
@@ -167,7 +166,7 @@ public class AssetDownloadTask extends AbstractMFTask {
         XmlStringWriter w = new XmlStringWriter();
         w.add("id", _assetId != null ? _assetId : ("path=" + _assetPath));
 
-        session().execute("asset.get", w.document(), (List<ServerClient.Input>) null, output, this);
+        session().execute("asset.get", w.document(), output, this);
     }
 
     private boolean needToUnarchive(XmlDoc.Element ae) throws Throwable {
diff --git a/src/main/java/unimelb/mf/client/sync/task/FileUploadTask.java b/src/main/java/unimelb/mf/client/sync/task/FileUploadTask.java
index a42ffbd6f489b09a410c64be0df8bf4b399e73ce..04a6b1598cde2537740239a22c594af348a30a42 100644
--- a/src/main/java/unimelb/mf/client/sync/task/FileUploadTask.java
+++ b/src/main/java/unimelb/mf/client/sync/task/FileUploadTask.java
@@ -132,7 +132,8 @@ public class FileUploadTask extends AbstractMFTask {
 
             setCurrentOperation("Uploading file: '" + _file + "' to asset: '" + _assetPath + "'");
             logInfo("Uploading file: '" + _file + "' to asset: '" + _assetPath + "'");
-            XmlDoc.Element re = session().execute("service.execute", w2.document(), input, null, this);
+
+            XmlDoc.Element re = session().execute("service.execute", w2.document(), input, this);
             if (_csumCheck) {
                 XmlDoc.Element ae = re.element("reply[@service='asset.get']/response/asset");
                 _assetId = re.value("id");
diff --git a/src/main/java/unimelb/mf/client/task/AbstractMFAppCLI.java b/src/main/java/unimelb/mf/client/task/AbstractMFAppCLI.java
index 4b80226a7d7b0ee867ccf5d8ba97a2e28b2042ed..a571585102bf7216ad4dbcd59fbc51c865ae9638 100644
--- a/src/main/java/unimelb/mf/client/task/AbstractMFAppCLI.java
+++ b/src/main/java/unimelb/mf/client/task/AbstractMFAppCLI.java
@@ -4,7 +4,7 @@ import java.io.PrintStream;
 import java.util.List;
 import java.util.logging.Logger;
 
-import unimelb.mf.client.session.MFConnectionSettings;
+import unimelb.mf.client.session.MFConfigurationBuilder;
 import unimelb.mf.client.session.MFSession;
 import unimelb.mf.client.util.LoggingUtils;
 
@@ -12,12 +12,13 @@ public abstract class AbstractMFAppCLI<T extends MFApp.Settings> extends Abstrac
 
     private T _settings;
 
-    protected AbstractMFAppCLI(Logger logger) {
+    protected AbstractMFAppCLI(Logger logger, T settings) {
         if (logger == null) {
             setLogger(LoggingUtils.createConsoleLogger());
         } else {
             setLogger(logger);
         }
+        _settings = settings;
     }
 
     @Override
@@ -27,25 +28,26 @@ public abstract class AbstractMFAppCLI<T extends MFApp.Settings> extends Abstrac
 
     @Override
     public void execute(String[] args) throws Throwable {
+        execute(args, true);
+    }
+
+    protected void execute(String[] args, boolean discardSession) throws Throwable {
         try {
-            MFConnectionSettings cxnSettings = new MFConnectionSettings();
-            cxnSettings.findAndLoadFromConfigFile();
-            cxnSettings.setApp(applicationName());
-            List<String> remainArgs = cxnSettings.parseArgs(args);
-            MFSession session = new MFSession(cxnSettings);
-            try {
-                if (cxnSettings.hasMissingArgument()) {
-                    session.doConsoleLogon();
-                } else {
-                    session.testAuthentication();
-                }
-                setSession(session);
-                _settings = parseSettings(session, remainArgs);
+            MFConfigurationBuilder config = new MFConfigurationBuilder().setConsoleLogon(true);
+            config.findAndLoadFromConfigFile();
+            config.setApp(applicationName());
+            List<String> remainArgs = config.parseArgs(args);
 
-                execute();
+            MFSession session = MFSession.create(config);
+            setSession(session);
 
+            try {
+                parseSettings(session, remainArgs);
+                execute();
             } finally {
-                session.discard();
+                if (discardSession) {
+                    session.discard();
+                }
             }
         } catch (IllegalArgumentException iae) {
             iae.printStackTrace();
@@ -55,31 +57,17 @@ public abstract class AbstractMFAppCLI<T extends MFApp.Settings> extends Abstrac
         }
     }
 
-    protected void printMFArgs(PrintStream ps, int indent) {
-        String indentStr = "";
-        if (indent > 0) {
-            indentStr = new String(new char[indent]).replace("\0", " ");
-        }
+    protected void printMFArgs(PrintStream ps) {
         // @formatter:off
-        ps.print(indentStr);
-        ps.println("--mf.config <mflux.cfg>                   Path to the config file that contains Mediaflux server details and user credentials.");
-        ps.print(indentStr);
-        ps.println("--mf.host <host>                          Mediaflux server host.");
-        ps.print(indentStr);
-        ps.println("--mf.port <port>                          Mediaflux server port.");
-        ps.print(indentStr);
-        ps.println("--mf.transport <https|http|tcp/ip>        Mediaflux server transport, can be http, https or tcp/ip.");
-        ps.print(indentStr);
-        ps.println("--mf.auth <domain,user,password>          Mediaflux user credentials.");
-        ps.print(indentStr);
-        ps.println("--mf.token <token>                        Mediaflux secure identity token.");
+        ps.println("    --mf.config <mflux.cfg>               Path to the config file that contains Mediaflux server details and user credentials.");
+        ps.println("    --mf.host <host>                      Mediaflux server host.");
+        ps.println("    --mf.port <port>                      Mediaflux server port.");
+        ps.println("    --mf.transport <https|http|tcp/ip>    Mediaflux server transport, can be http, https or tcp/ip.");
+        ps.println("    --mf.auth <domain,user,password>      Mediaflux user credentials.");
+        ps.println("    --mf.token <token>                    Mediaflux secure identity token.");
         // @formatter:on
     }
 
-    protected void printMFArgs(PrintStream ps) {
-        printMFArgs(ps, 4);
-    }
-
-    protected abstract T parseSettings(MFSession session, List<String> args) throws Throwable;
+    protected abstract void parseSettings(MFSession session, List<String> args) throws Throwable;
 
 }
diff --git a/src/main/java/unimelb/mf/client/task/AbstractMFTask.java b/src/main/java/unimelb/mf/client/task/AbstractMFTask.java
index ffe2bdb978a4c6adee8d371a7c4acb0c807510a3..9d4d25dcfb175073a218195bdfea13a3cc36d20e 100644
--- a/src/main/java/unimelb/mf/client/task/AbstractMFTask.java
+++ b/src/main/java/unimelb/mf/client/task/AbstractMFTask.java
@@ -31,13 +31,15 @@ public abstract class AbstractMFTask implements MFTask {
     }
 
     @Override
-    public void setAbortableOperation(CanAbort ca) {
+    public void started(CanAbort ca) {
         _ca = ca;
     }
 
     @Override
-    public CanAbort abortableOperation() {
-        return _ca;
+    public void finished(CanAbort ca) {
+        if (_ca != null && _ca.equals(ca)) {
+            _ca = null;
+        }
     }
 
     @Override
@@ -84,10 +86,9 @@ public abstract class AbstractMFTask implements MFTask {
             logError(e);
             if (e instanceof InterruptedException) {
                 Thread.currentThread().interrupt();
-                CanAbort ca = abortableOperation();
-                if (ca != null) {
+                if (_ca != null) {
                     try {
-                        ca.abort();
+                        _ca.abort();
                     } catch (Throwable e1) {
                         logError("Fail to abort service call.", e1);
                     }
diff --git a/src/main/java/unimelb/mf/client/task/MFTask.java b/src/main/java/unimelb/mf/client/task/MFTask.java
index 2906427111d5f4a342ffe21a643e423759db435b..b5eb499ad463a73f9b10f844dddae841b0d08208 100644
--- a/src/main/java/unimelb/mf/client/task/MFTask.java
+++ b/src/main/java/unimelb/mf/client/task/MFTask.java
@@ -2,12 +2,12 @@ package unimelb.mf.client.task;
 
 import java.util.concurrent.Callable;
 
+import arc.utils.AbortableOperationHandler;
 import unimelb.mf.client.session.HasMFSession;
-import unimelb.mf.client.util.HasAbortableOperation;
 import unimelb.mf.client.util.HasProgress;
 import unimelb.mf.client.util.Loggable;
 
-public interface MFTask extends Callable<Void>, Loggable, HasMFSession, HasAbortableOperation, HasProgress {
+public interface MFTask extends Callable<Void>, Loggable, HasMFSession, AbortableOperationHandler, HasProgress {
 
     void execute() throws Throwable;
 }
diff --git a/src/main/java/unimelb/mf/client/terminal/MFTerminal.java b/src/main/java/unimelb/mf/client/terminal/MFTerminal.java
index f92e1370b68d6184f017a33e87a09d8223a1f3f3..cad75e3ea5f6d79ed7a057c90b71db6ded1d98de 100644
--- a/src/main/java/unimelb/mf/client/terminal/MFTerminal.java
+++ b/src/main/java/unimelb/mf/client/terminal/MFTerminal.java
@@ -297,7 +297,7 @@ public class MFTerminal extends AbstractMFAppCLI<MFTerminal.Settings> {
     private Logger _termLogger;
 
     public MFTerminal() throws Throwable {
-        super(Logger.getLogger("org.jline"));
+        super(Logger.getLogger("org.jline"), new Settings());
         _dir = Paths.get(System.getProperty("user.dir")).toAbsolutePath();
         this.logger().setLevel(Level.FINE);
         _termLogger = Logger.getLogger(APP_NAME);
@@ -502,17 +502,15 @@ public class MFTerminal extends AbstractMFAppCLI<MFTerminal.Settings> {
     }
 
     @Override
-    protected Settings parseSettings(MFSession session, List<String> args) throws Throwable {
-        Settings settings = new Settings();
+    protected void parseSettings(MFSession session, List<String> args) throws Throwable {
         for (int i = 0; i < args.size();) {
             if ("-h".equalsIgnoreCase(args.get(i)) || "--help".equalsIgnoreCase(args.get(i))) {
-                settings.setShowHelp(true);
+                settings().setShowHelp(true);
                 i++;
             } else {
                 throw new IllegalArgumentException("Unexpected argument: " + args.get(i));
             }
         }
-        return settings;
     }
 
     @Override
@@ -582,4 +580,5 @@ public class MFTerminal extends AbstractMFAppCLI<MFTerminal.Settings> {
         }
 
     }
+
 }
diff --git a/src/main/java/unimelb/mf/client/util/AssetUtils.java b/src/main/java/unimelb/mf/client/util/AssetUtils.java
index b8bce1a02d9ceb84772b87254407b6aa3e1515af..8fb9887e370552b57516a8c47e99ac81d94981ae 100644
--- a/src/main/java/unimelb/mf/client/util/AssetUtils.java
+++ b/src/main/java/unimelb/mf/client/util/AssetUtils.java
@@ -123,4 +123,10 @@ public class AssetUtils {
         }
     }
 
+    public static boolean assetExists(MFSession session, String assetId) throws Throwable {
+        XmlStringWriter w = new XmlStringWriter();
+        w.add("id", assetId);
+        return session.execute("asset.exists", w.document()).booleanValue("exists");
+    }
+
 }
diff --git a/src/main/java/unimelb/mf/client/util/LoggingUtils.java b/src/main/java/unimelb/mf/client/util/LoggingUtils.java
index c158840ca40dc8e217729ebfb44d93d7457f1880..cf385c0dbc881c1a7c8ebe7b29341415b6880d2e 100644
--- a/src/main/java/unimelb/mf/client/util/LoggingUtils.java
+++ b/src/main/java/unimelb/mf/client/util/LoggingUtils.java
@@ -25,7 +25,7 @@ public class LoggingUtils {
 
     public static FileHandler createFileHandler(Path dir, String name, int logFileSizeLimit, int logFileCount,
             Level level, Formatter formatter) throws Throwable {
-        String logFileNamePattern = dir.toString() + File.separatorChar + name + "." + "%g.log";
+        String logFileNamePattern = dir.toString() + File.separatorChar + name + "." + "%u.%g.log";
         FileHandler fileHandler = new FileHandler(logFileNamePattern, logFileSizeLimit, logFileCount, true);
         if (formatter != null) {
             fileHandler.setFormatter(formatter);
diff --git a/src/main/scripts/unix/aterm b/src/main/scripts/unix/aterm
index c25e59b5a82f63341a473d410de329309417fed7..963c88ceed95e695422b7b4ab174651f8db15654 100755
--- a/src/main/scripts/unix/aterm
+++ b/src/main/scripts/unix/aterm
@@ -1,7 +1,7 @@
 #!/bin/bash
 
 # where to download aterm.jar?
-ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 # where to save aterm.jar? 
 ATERM_HOME=$HOME/.Arcitecta
diff --git a/src/main/scripts/unix/aterm-download b/src/main/scripts/unix/aterm-download
index 97334b8dbf1d0612ba17de955d7b51c4e17b5ef0..13d2a7d5688d8964abb41cf3e539778cc2a051ac 100755
--- a/src/main/scripts/unix/aterm-download
+++ b/src/main/scripts/unix/aterm-download
@@ -1,7 +1,7 @@
 #!/bin/bash
 
 # where to download aterm.jar?
-ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 # where to save aterm.jar? 
 ATERM_HOME=$HOME/.Arcitecta
diff --git a/src/main/scripts/unix/aterm-gui b/src/main/scripts/unix/aterm-gui
index dd9b3f67caee2db6761d7ca751651dba8e7516ac..9bb64dd79ac1869029535e007c41d61ba6d6afde 100644
--- a/src/main/scripts/unix/aterm-gui
+++ b/src/main/scripts/unix/aterm-gui
@@ -1,7 +1,7 @@
 #!/bin/bash
 
 # where to download aterm.jar?
-ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 # where to save aterm.jar? 
 ATERM_HOME=$HOME/.Arcitecta
diff --git a/src/main/scripts/unix/aterm-import b/src/main/scripts/unix/aterm-import
index fb0ea56d6848ab6dec369470307e2c332fe5c804..2ab07ebc3b455326e58733d13eb721e675c2fee6 100755
--- a/src/main/scripts/unix/aterm-import
+++ b/src/main/scripts/unix/aterm-import
@@ -1,7 +1,7 @@
 #!/bin/bash
 
 # where to download aterm.jar?
-ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 # where to save aterm.jar? 
 ATERM_HOME=$HOME/.Arcitecta
diff --git a/src/main/scripts/unix/mexplorer b/src/main/scripts/unix/mexplorer
index 572efd433363959164b9c2934a1a3dfcf9d063d2..3aeb939ee73d3d84ddf74c29da4aaeda17f7fa97 100644
--- a/src/main/scripts/unix/mexplorer
+++ b/src/main/scripts/unix/mexplorer
@@ -1,23 +1,29 @@
 #!/bin/bash
-  
-# check if java exists
-[[ -z $(which java) ]] && echo "Error: cannot find java." 1>&2 && exit 1
 
+# ${ROOT}/bin/
+BIN=$(dirname ${BASH_SOURCE[0]})
+
+# current directory
 CWD=$(pwd)
 
-BIN=$(dirname ${BASH_SOURCE[0]})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
 
-LIB=$(cd ${BIN}/../../lib && pwd)
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-cd $LIB
+# ${ROOT}/lib/mexplorer.jar
+JAR=${LIB}/mexplorer.jar
 
-for f in $(ls -d mexplorer-*.jar)
-do
-    JAR=$f
-done
+# check if mexplorer.jar exists
+[[ ! -f $JAR ]] && echo "Error: cannot find ${JAR}." >&2 && exit 2
 
-[[ -z ${JAR} ]] && echo "Error: cannot find mexplorer-*.jar in ${LIB}" && exit 2
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+  
+# check if java exists
+[[ -z $(which java) ]] && echo "Error: cannot find java." 1>&2 && exit 1
 
-cd $CWD
+# 
+java -Xmx1024m -jar ${JAR}
 
-java -jar ${LIB}/${JAR}
\ No newline at end of file
diff --git a/src/main/scripts/unix/scp-get b/src/main/scripts/unix/scp-get
index 33547ad627ccaaf05ea19e7a30ffd6fb6eb2fe6c..d9a3d184ce0ffbd4996839906163a2f18f93cc8b 100644
--- a/src/main/scripts/unix/scp-get
+++ b/src/main/scripts/unix/scp-get
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.ssh.cli.SCPGetCLI ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.ssh.cli.SCPGetCLI ${1+"$@"}
diff --git a/src/main/scripts/unix/scp-put b/src/main/scripts/unix/scp-put
index 1fe8e4ba6c6617ca57791dde9aa79f5948fe7c01..b891b369331126d3a236cec6134b044fcd865864 100644
--- a/src/main/scripts/unix/scp-put
+++ b/src/main/scripts/unix/scp-put
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.ssh.cli.SCPPutCLI ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.ssh.cli.SCPPutCLI ${1+"$@"}
diff --git a/src/main/scripts/unix/sftp-get b/src/main/scripts/unix/sftp-get
index ef71fdbb268b91d542ef8d2e08d7b8d818c56760..c2417711697d4cb9954d31e194982daa2828df47 100644
--- a/src/main/scripts/unix/sftp-get
+++ b/src/main/scripts/unix/sftp-get
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.ssh.cli.SFTPGetCLI ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.ssh.cli.SFTPGetCLI ${1+"$@"}
diff --git a/src/main/scripts/unix/sftp-put b/src/main/scripts/unix/sftp-put
index d842bf2888c64d496b8c9d4237b13aaf03c1cca0..d9bf51bc74348c6e5f7c392d8a92cf851037af80 100644
--- a/src/main/scripts/unix/sftp-put
+++ b/src/main/scripts/unix/sftp-put
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.ssh.cli.SFTPPutCLI ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.ssh.cli.SFTPPutCLI ${1+"$@"}
diff --git a/src/main/scripts/unix/unimelb-mf-archive b/src/main/scripts/unix/unimelb-mf-archive
index 7271637fba36dc9929461090a4eac3011b004368..32abc05885f6692f6e6234ec00a355c14bc57199 100644
--- a/src/main/scripts/unix/unimelb-mf-archive
+++ b/src/main/scripts/unix/unimelb-mf-archive
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.archive.cli.MFArchiveCLI ${1+"$@"}
\ No newline at end of file
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.archive.cli.MFArchiveCLI ${1+"$@"}
\ No newline at end of file
diff --git a/src/main/scripts/unix/unimelb-mf-check b/src/main/scripts/unix/unimelb-mf-check
index 88c4ec998f0ce74d76447a0977d2519da85926a7..a72dca3c5d9541ce4850150a50a9d2bfe3d25e3b 100644
--- a/src/main/scripts/unix/unimelb-mf-check
+++ b/src/main/scripts/unix/unimelb-mf-check
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.sync.cli.MFCheck ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.sync.cli.MFCheck ${1+"$@"}
diff --git a/src/main/scripts/unix/unimelb-mf-download b/src/main/scripts/unix/unimelb-mf-download
index a8e053ea4aa3373cfa5fb01706a2e712c9f3e108..6be8beb9c0b9864e856c6533d1affc236376b520 100644
--- a/src/main/scripts/unix/unimelb-mf-download
+++ b/src/main/scripts/unix/unimelb-mf-download
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.sync.cli.MFDownload ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.sync.cli.MFDownload ${1+"$@"}
diff --git a/src/main/scripts/unix/unimelb-mf-perf b/src/main/scripts/unix/unimelb-mf-perf
new file mode 100644
index 0000000000000000000000000000000000000000..bb4c41a0e0618749fb498d94370c37128dbeb74c
--- /dev/null
+++ b/src/main/scripts/unix/unimelb-mf-perf
@@ -0,0 +1,28 @@
+#!/bin/bash
+
+# ${ROOT}/bin/
+BIN=$(dirname ${BASH_SOURCE[0]})
+
+# current directory
+CWD=$(pwd)
+
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
+
+# ${ROOT}/lib/unimelb-mf-clients.jar
+JAR=${LIB}/unimelb-mf-clients.jar
+
+# check if unimelb-mf-clients.jar exists
+[[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
+
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
+# execute the command
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.perf.cli.MFPerfCLI ${1+"$@"}
\ No newline at end of file
diff --git a/src/main/scripts/unix/unimelb-mf-sync b/src/main/scripts/unix/unimelb-mf-sync
index 00f60290c7a57128b2fb2f777fccd964d4fa19e4..4913a1478ab87450f034c8d726b3227f44c0de24 100644
--- a/src/main/scripts/unix/unimelb-mf-sync
+++ b/src/main/scripts/unix/unimelb-mf-sync
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.sync.cli.MFSync ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.sync.cli.MFSync ${1+"$@"}
diff --git a/src/main/scripts/unix/unimelb-mf-upload b/src/main/scripts/unix/unimelb-mf-upload
index bd097ef72cf065349cbbd7d112ba2d8b6736491a..b82ac386214c41ac0db619ed74a8d96b5f871ad2 100644
--- a/src/main/scripts/unix/unimelb-mf-upload
+++ b/src/main/scripts/unix/unimelb-mf-upload
@@ -1,22 +1,28 @@
 #!/bin/bash
 
-# check if java exists
-[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
-
-# unimelb-mf-clients/bin
+# ${ROOT}/bin/
 BIN=$(dirname ${BASH_SOURCE[0]})
 
 # current directory
 CWD=$(pwd)
 
-# unimelb-mf-clients/lib
-LIB=$(cd ${BIN}/../../lib && pwd && cd ${CWD})
+# ${ROOT}/
+ROOT=$(cd ${BIN}/../../ && pwd && cd ${CWD})
+
+# ${ROOT}/lib/
+LIB=${ROOT}/lib
 
-# unimelb-mf-clients/lib/unimelb-mf-clients.jar
+# ${ROOT}/lib/unimelb-mf-clients.jar
 JAR=${LIB}/unimelb-mf-clients.jar
 
 # check if unimelb-mf-clients.jar exists
 [[ ! -f $JAR ]] && echo "${JAR} is not found." >&2 && exit 2
 
+#export JAVA_HOME=${ROOT}/@JAVA_HOME@
+#export PATH=${JAVA_HOME}/bin:${PATH}
+
+# check if java exists
+[[ -z $(which java) ]] && echo "Java is not found." >&2 && exit 1
+
 # execute the command
-java -cp "${JAR}" unimelb.mf.client.sync.cli.MFUpload ${1+"$@"}
+java -Xmx200m -cp "${JAR}" unimelb.mf.client.sync.cli.MFUpload ${1+"$@"}
diff --git a/src/main/scripts/windows/aterm-download.cmd b/src/main/scripts/windows/aterm-download.cmd
index 107c92767a0b45dacfc3fcbe2722218405ad1c9f..412163040c8fdc5f5ac26f17bc28067bff2073cb 100644
--- a/src/main/scripts/windows/aterm-download.cmd
+++ b/src/main/scripts/windows/aterm-download.cmd
@@ -1,7 +1,7 @@
 @echo off
 
 REM where to download aterm.jar
-SET ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+SET ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 REM where to save aterm.jar
 SET ATERM_HOME=%USERPROFILE%\.Arcitecta
diff --git a/src/main/scripts/windows/aterm-gui.cmd b/src/main/scripts/windows/aterm-gui.cmd
index e4e726bb3801bf000d1bea45f027bd32c1ac83a8..3bac8eccc2d815162bb63cb4767c52cda96f627b 100644
--- a/src/main/scripts/windows/aterm-gui.cmd
+++ b/src/main/scripts/windows/aterm-gui.cmd
@@ -1,7 +1,7 @@
 @echo off
 
 REM where to download aterm.jar
-SET ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+SET ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 REM where to save aterm.jar
 SET ATERM_HOME=%USERPROFILE%\.Arcitecta
diff --git a/src/main/scripts/windows/aterm-import.cmd b/src/main/scripts/windows/aterm-import.cmd
index f578603660f91e0a76fb6ab91078c9b946aa3c13..8eec6432ba14596e41e93dc03ecf9d6da820cda6 100644
--- a/src/main/scripts/windows/aterm-import.cmd
+++ b/src/main/scripts/windows/aterm-import.cmd
@@ -2,7 +2,7 @@
 
 
 REM where to download aterm.jar
-SET ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+SET ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 REM where to save aterm.jar
 SET ATERM_HOME=%USERPROFILE%\.Arcitecta
diff --git a/src/main/scripts/windows/aterm.cmd b/src/main/scripts/windows/aterm.cmd
index 06f66de4ec999feefdc2db1272b9db4618ba6380..794790bdda52f51b52bd55513fa79bef48107c08 100644
--- a/src/main/scripts/windows/aterm.cmd
+++ b/src/main/scripts/windows/aterm.cmd
@@ -1,7 +1,7 @@
 @echo off
 
 REM where to download aterm.jar
-SET ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+SET ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 REM where to save aterm.jar
 SET ATERM_HOME=%USERPROFILE%\.Arcitecta
diff --git a/src/main/scripts/windows/cryo-em/cryoem-download-aterm-script-url-create.cmd b/src/main/scripts/windows/cryo-em/cryoem-download-aterm-script-url-create.cmd
index 49abea14bee33cb16e92afac7ba127eb8f2997fa..2439557727987cb06a841832c6222d496f12db9d 100644
--- a/src/main/scripts/windows/cryo-em/cryoem-download-aterm-script-url-create.cmd
+++ b/src/main/scripts/windows/cryo-em/cryoem-download-aterm-script-url-create.cmd
@@ -12,7 +12,7 @@ REM script file name
 set PROG=%~0
 
 REM aterm.jar download url
-set ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+set ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 REM aterm.jar location
 if [%MFLUX_ATERM%]==[] set "MFLUX_ATERM=%~dp0..\..\..\lib\aterm.jar"
diff --git a/src/main/scripts/windows/cryo-em/cryoem-download-shell-script-url-create.cmd b/src/main/scripts/windows/cryo-em/cryoem-download-shell-script-url-create.cmd
index f1e1b35a6b267752adfc037c68634ff2e0b88802..7f7093adc99f3325c598f054522851afbcdf7180 100644
--- a/src/main/scripts/windows/cryo-em/cryoem-download-shell-script-url-create.cmd
+++ b/src/main/scripts/windows/cryo-em/cryoem-download-shell-script-url-create.cmd
@@ -12,7 +12,7 @@ REM script file name
 set PROG=%~0
 
 REM aterm.jar download url
-set ATERM_URL=https://mediaflux.vicnode.org.au/mflux/aterm.jar
+set ATERM_URL=https://mediaflux.researchsoftware.unimelb.edu.au/mflux/aterm.jar
 
 REM aterm.jar location
 if [%MFLUX_ATERM%]==[] set "MFLUX_ATERM=%~dp0..\..\..\lib\aterm.jar"
diff --git a/src/main/scripts/windows/mexplorer.cmd b/src/main/scripts/windows/mexplorer.cmd
index db3c8241d58898477f86a2abbd4db2614f422eca..7cd5bca06d63a279581ade921103a24fd55297fa 100644
--- a/src/main/scripts/windows/mexplorer.cmd
+++ b/src/main/scripts/windows/mexplorer.cmd
@@ -1,9 +1,12 @@
 @echo off
 
-pushd %~dp0..\..\lib
-set LIB=%cd%
-for %%# in ("mexplorer-*.jar") do (
-   set JAR=%%~#
-)
+pushd %~dp0..\..\
+set ROOT=%cd%
 popd
-java -jar %LIB%\%JAR% %*
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\mexplorer.jar
+
+java -Xmx1024m -jar %JAR% %*
diff --git a/src/main/scripts/windows/scp-get.cmd b/src/main/scripts/windows/scp-get.cmd
index ee1f548995cd5a68b2f7856ee4357a38b0ddd480..f7ea2284ec70226524951dc9f86e7856b83c5b2a 100644
--- a/src/main/scripts/windows/scp-get.cmd
+++ b/src/main/scripts/windows/scp-get.cmd
@@ -1,5 +1,14 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.ssh.cli.SCPGetCLI %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp %JAR% unimelb.mf.client.ssh.cli.SCPGetCLI %*
 
 
diff --git a/src/main/scripts/windows/scp-put.cmd b/src/main/scripts/windows/scp-put.cmd
index 1eb56cfd73fe358cb11bbec1c110273a5696ef75..123edff4ec33bc70ad055c4fd26f46152cfbcc64 100644
--- a/src/main/scripts/windows/scp-put.cmd
+++ b/src/main/scripts/windows/scp-put.cmd
@@ -1,3 +1,12 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.ssh.cli.SCPPutCLI %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp %JAR% unimelb.mf.client.ssh.cli.SCPPutCLI %*
diff --git a/src/main/scripts/windows/sftp-get.cmd b/src/main/scripts/windows/sftp-get.cmd
index 34b615bc9021373a2192638a4cccaea1a2fa3914..fd9b3aa26a215065ccb453894b2f79545185cef3 100644
--- a/src/main/scripts/windows/sftp-get.cmd
+++ b/src/main/scripts/windows/sftp-get.cmd
@@ -1,3 +1,12 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.ssh.cli.SFTPGetCLI %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp %JAR% unimelb.mf.client.ssh.cli.SFTPGetCLI %*
diff --git a/src/main/scripts/windows/sftp-put.cmd b/src/main/scripts/windows/sftp-put.cmd
index a2d31df078c1138b5f74973663f2d3cd70f89845..614aabe0ef3573df561a9a7599dc4cd0f4f762b0 100644
--- a/src/main/scripts/windows/sftp-put.cmd
+++ b/src/main/scripts/windows/sftp-put.cmd
@@ -1,3 +1,12 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.ssh.cli.SFTPPutCLI %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp  %JAR% unimelb.mf.client.ssh.cli.SFTPPutCLI %*
diff --git a/src/main/scripts/windows/unimelb-mf-archive.cmd b/src/main/scripts/windows/unimelb-mf-archive.cmd
index 50c86653c228b1c67974733ccdc8433d4cb29a36..e898767abc2af0b0a5012515b9fb25cad012d53e 100644
--- a/src/main/scripts/windows/unimelb-mf-archive.cmd
+++ b/src/main/scripts/windows/unimelb-mf-archive.cmd
@@ -1,3 +1,12 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.archive.cli.MFArchiveCLI %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp %JAR% unimelb.mf.client.archive.cli.MFArchiveCLI %*
diff --git a/src/main/scripts/windows/unimelb-mf-check.cmd b/src/main/scripts/windows/unimelb-mf-check.cmd
index e3d5a8a9fe7ac583e9b8af379eb17fbd1f14b29b..7956a1d6134cd75cc7ec89659c23ab5bb5c2b062 100644
--- a/src/main/scripts/windows/unimelb-mf-check.cmd
+++ b/src/main/scripts/windows/unimelb-mf-check.cmd
@@ -1,3 +1,12 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.sync.cli.MFCheck %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp %JAR% unimelb.mf.client.sync.cli.MFCheck %*
diff --git a/src/main/scripts/windows/unimelb-mf-download.cmd b/src/main/scripts/windows/unimelb-mf-download.cmd
index ba23f927f1f5625abb56d980823c48556e09077e..bce97c26f12b50a4cf03f15f4c9f67d0fef3fa23 100644
--- a/src/main/scripts/windows/unimelb-mf-download.cmd
+++ b/src/main/scripts/windows/unimelb-mf-download.cmd
@@ -1,3 +1,11 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.sync.cli.MFDownload %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+java -Xmx200m -cp %JAR% unimelb.mf.client.sync.cli.MFDownload %*
diff --git a/src/main/scripts/windows/unimelb-mf-perf.cmd b/src/main/scripts/windows/unimelb-mf-perf.cmd
new file mode 100644
index 0000000000000000000000000000000000000000..db817c8cfd4b22323174eeeaeb1182d70f323e3f
--- /dev/null
+++ b/src/main/scripts/windows/unimelb-mf-perf.cmd
@@ -0,0 +1,11 @@
+@echo off
+
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+java -Xmx200m -cp %JAR% unimelb.mf.client.perf.cli.MFPerfCLI %*
\ No newline at end of file
diff --git a/src/main/scripts/windows/unimelb-mf-sync.cmd b/src/main/scripts/windows/unimelb-mf-sync.cmd
index 9cd21a5b30d593c55dfff2e081c2eb22ebd8520e..fee85fc389fce8c3a0900b75112f9dfc87c7955d 100644
--- a/src/main/scripts/windows/unimelb-mf-sync.cmd
+++ b/src/main/scripts/windows/unimelb-mf-sync.cmd
@@ -1,3 +1,12 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.sync.cli.MFSync %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp %JAR% unimelb.mf.client.sync.cli.MFSync %*
diff --git a/src/main/scripts/windows/unimelb-mf-upload.cmd b/src/main/scripts/windows/unimelb-mf-upload.cmd
index 58267744af355f511de6511fbc0f1b181f082669..6271708149d63c8505fc516bb5843d2098c90393 100644
--- a/src/main/scripts/windows/unimelb-mf-upload.cmd
+++ b/src/main/scripts/windows/unimelb-mf-upload.cmd
@@ -1,3 +1,12 @@
 @echo off
 
-cmd /k java -cp "%~dp0..\..\lib\unimelb-mf-clients.jar" unimelb.mf.client.sync.cli.MFUpload %*
+pushd %~dp0..\..\
+set ROOT=%cd%
+popd
+
+@REM set JAVA_HOME=%ROOT%\@JAVA_HOME@
+@REM set PATH=%JAVA_HOME%\bin;%PATH%
+
+set JAR=%ROOT%\lib\unimelb-mf-clients.jar
+
+java -Xmx200m -cp %JAR% unimelb.mf.client.sync.cli.MFUpload %*
diff --git a/src/test/java/unimelb/mf/client/AppTest.java b/src/test/java/unimelb/mf/client/AppTest.java
deleted file mode 100644
index d88e7020a804937c1222a904a63d46a5de131158..0000000000000000000000000000000000000000
--- a/src/test/java/unimelb/mf/client/AppTest.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package unimelb.mf.client;
-
-import junit.framework.Test;
-import junit.framework.TestCase;
-import junit.framework.TestSuite;
-
-/**
- * Unit test for simple App.
- */
-public class AppTest 
-    extends TestCase
-{
-    /**
-     * Create the test case
-     *
-     * @param testName name of the test case
-     */
-    public AppTest( String testName )
-    {
-        super( testName );
-    }
-
-    /**
-     * @return the suite of tests being tested
-     */
-    public static Test suite()
-    {
-        return new TestSuite( AppTest.class );
-    }
-
-    /**
-     * Rigourous Test :-)
-     */
-    public void testApp()
-    {
-        assertTrue( true );
-    }
-}
diff --git a/src/test/java/unimelb/mf/client/SCPTest.java b/src/test/java/unimelb/mf/client/SCPTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..e56078c2a04cdd3ffbbc9ee31cd2088bf4ce28a5
--- /dev/null
+++ b/src/test/java/unimelb/mf/client/SCPTest.java
@@ -0,0 +1,24 @@
+package unimelb.mf.client;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SCPTest {
+
+    @Before
+    public void setup() {
+
+    }
+
+    @Test
+    public void test() {
+
+    }
+
+    @After
+    public void cleanup() {
+
+    }
+
+}
diff --git a/src/test/java/unimelb/mf/client/TestUtils.java b/src/test/java/unimelb/mf/client/TestUtils.java
new file mode 100644
index 0000000000000000000000000000000000000000..8ec38e1d369fa184d4599abcd89984e501da5d44
--- /dev/null
+++ b/src/test/java/unimelb/mf/client/TestUtils.java
@@ -0,0 +1,41 @@
+package unimelb.mf.client;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+
+public class TestUtils {
+
+    public static final String PROPERTIES_FILE = System.getProperty("user.home")
+            + "/.junit/unimelb-mf-clients-ssh-test.properties";
+
+    public static final String MFLUX_CONFIG_FILE = System.getProperty("user.home") + "/.Arcitecta/mflux.cfg.local";
+
+    public static final Properties properties = new Properties();
+
+    static {
+        try {
+            Reader r = new BufferedReader(new FileReader(new File(PROPERTIES_FILE)));
+            try {
+                properties.load(r);
+            } finally {
+                r.close();
+            }
+        } catch (Throwable t) {
+            t.printStackTrace();
+        }
+    }
+
+    public static final List<String> putArgs = new ArrayList<String>();
+
+    public static final List<String> getArgs = new ArrayList<String>();
+
+    static {
+
+    }
+
+}