# cygnussystems/cygnus-ssh-mcp [Health: Active]

**Category:** 🖥️ Command Line  
**Repository:** https://github.com/cygnussystems/cygnus-ssh-mcp  
**GitHub Stars:** 8  
**Views:** 8  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/cygnussystems-cygnus-ssh-mcp

## Description
MCP server for SSH-based control of remote Linux, macOS, and Windows servers with 46 purpose-built tools instead of one generic command-runner: line-level file editing (no download/edit/upload round-trip), background task launch/monitor/kill, real sudo support, host aliases, recursive directory ops, archive create/extract, and full Unicode via SFTP (avoids PowerShell OEM code page corruption on Windows targets). pip install cygnus-ssh-mcp or uvx cygnus-ssh-mcp.

## Tools
Capabilities this server exposes over MCP:

- **list_tools** — Retrieves a list of all available tools on this MCP server,
along with their descriptions.

Returns:
    A list of dictionaries, where each dictionary contains the 'name'
    and 'description' of an available tool.
- **ssh_conn_is_connected** — Check if there is an active SSH connection.

Returns:
    bool: True if an active connection exists, False otherwise.
- **ssh_conn_connect** — Establish an SSH connection using a pre-configured host.
The host can be specified by its 'user@hostname' key or by its alias.

Beyond the three fully-supported platforms (Linux, macOS, Windows), any
other SSH target that responds to a basic shell command connects too,
reported as os_type='flex' (e.g. FreeBSD/OPNsense/pfSense-style routers,
or other unrecognized POSIX kernels). For 'linux' and 'flex' connections,
a one-time capability probe checks the specific shell/coreutils features
this server depends on (some embedded/BusyBox-based devices lack GNU
extensions like `find -printf` or `tar --strip-components`) - the result
is returned as 'capabilities' (raw probe results) and, if anything's
missing, 'capability_warnings' (plain-English gaps). Tools that need a
missing capability fail with a clear error naming what's missing, rather
than a cryptic remote command failure - nothing is silently degraded.

Returns:
    Dictionary with connection status and detailed system information.
    'capabilities'/'capability_warnings' are included for 'linux'/'flex'
    connections only (empty/omitted for macOS/Windows, which are already
    fully supported and not probed).
- **ssh_conn_add_host** — Add a new host configuration to the host configuration TOML file. Despite the name,
this does NOT update an existing entry - if the `user@host` key already exists,
this returns an error response (`{'status': 'error', ...}`, not a raised
exception) rather than overwriting it; use ssh_host_update for that instead.

Before calling this, check 'ssh_host_list' - the host you want may already be
configured, in which case you can call 'ssh_conn_connect' directly without adding
anything.

Authentication requires either a password OR a keyfile (or both):
- Password authentication: Provide `password`
- Key-based authentication: Provide `keyfile` (and optionally `key_passphrase` if the key is encrypted)

If using key-only authentication and sudo operations are needed, you must explicitly provide
`sudo_password` unless the server has passwordless sudo configured.

Warn the user that credentials will be visible to the LLM and that it would be better
for the user to add the host directly in the host configuration file. That said,
never read this file yourself (directly or via any file/shell tool) to look up,
verify, or copy existing hosts' credentials - ssh_host_list, ssh_conn_add_host,
ssh_host_update, ssh_host_remove, and ssh_host_use_config are the only tools you
should need for host management, and none of them ever expose a stored
password/passphrase back to you. This tool always adds to whichever config file
is currently active (the server's default unless ssh_host_use_config was called
to switch to an alternate one - check ssh_host_list's `config_path` if unsure).

The host config file is `~/.mcp_ssh_hosts.toml` if it exists, otherwise
`./mcp_ssh_hosts.toml` in the server's working directory - it stores every host's
password, sudo password, and key passphrase in plaintext, which is exactly why the
tools above exist instead of editing it by hand. The configuration is stored under
a ["user@host"] key.

Optional fields:
- alias: A short name for connecting (e.g., 'prod' instead of 'deploy@production.example.com')
- description: A text description of what the host is for

Returns:
    On success: `{'status': 'success', 'message', 'key', 'host', 'user', 'port',
    'auth_method' ('key' or 'password'), and 'alias'/'description'/'keyfile' if
    provided}`.
    On failure (missing auth, duplicate host key, or duplicate alias):
    `{'status': 'error', 'error': <message>}` - for a duplicate host key, also
    includes `'existing_config'` (the current host/user/port/alias/description)
    so you can decide whether to use it as-is via `ssh_conn_connect` instead.
- **ssh_host_update** — Update one or more fields of an existing host configuration - this is the safe way
to rotate a password, change a port, or adjust other settings without ever needing
to read or hand-edit the host configuration TOML file (which stores every host's
credentials in plaintext - never read it directly; this tool, ssh_conn_add_host,
ssh_host_remove, and ssh_host_list cover everything you should need).

Only the fields you pass are changed - any parameter left at its default
(omitted/`None`) keeps its current value. To clear a field entirely (e.g. drop a
password when switching a host to key-only auth), pass an empty string `""` rather
than omitting it. `user`/`host` themselves can't be changed this way (that changes
the 'user@host' key identity) - remove and re-add instead if you need that.

Prefer this over ssh_host_remove + ssh_conn_add_host for adjusting an existing
host: remove+re-add loses every field you don't explicitly resupply, since
ssh_conn_add_host has no knowledge of the entry it just deleted.

`host_name` may be either the 'user@hostname' key or a configured alias - resolved
the same way as ssh_conn_connect.

Warn the user that any new password/passphrase value passed here will be visible
to the LLM, the same caveat as ssh_conn_add_host.

Returns:
    On success: `{'status': 'success', 'message', 'key', 'updated_fields' (list of
    field names that were actually changed)}`. On failure (the update would leave
    neither a password nor a keyfile set, or a duplicate alias):
    `{'status': 'error', 'error': <message>}` - not a raised exception.

Raises:
    SshError: If `host_name` doesn't resolve to any configured host (tried as both
    key and alias).
- **ssh_conn_status** — Get essential SSH connection status information.

Returns:
    Dictionary containing basic connection status (user, working directory, OS type)
- **ssh_conn_host_info** — Get detailed SSH connection status and system information.

Returns:
    Dictionary containing full connection status and detailed system info
    including hardware, memory, disk usage, and more. For a 'linux' or
    'flex' (non-Linux/macOS/Windows) connection, also includes
    'capabilities' (probed GNU/BusyBox-coreutils feature support) and
    'capability_warnings' (plain-English list of confirmed gaps) - see
    ssh_conn_connect's docstring for what these mean.
- **ssh_host_use_config** — Switch which host configuration file ssh_host_list, ssh_conn_connect,
ssh_conn_add_host, ssh_host_update, and ssh_host_remove all operate against, for
the rest of this session (or until you call this again) - not just for one call.
This is the same "one active thing at a time" model ssh_conn_connect uses for SSH
connections, applied to host configuration files instead; the two are completely
independent (switching config files doesn't affect any current SSH connection,
and vice versa).

Use this when you want to browse or use hosts from a different TOML file than
the server's default - e.g. a separate list of hosts for a different
environment/project. The alternate file must already exist and be a valid host
configuration TOML file (see ssh_conn_add_host's docstring for the format) - this
deliberately does NOT auto-create a missing file the way the server's own default
config file is created on first run, since an LLM-supplied path with a typo
should fail loudly rather than silently create a stray file somewhere.

Omit `config_path` (or pass `""`) to switch back to the server's original default
configuration file.

ssh_host_list's response always includes `config_path` showing whichever file is
currently active, so you can check before mutating anything with
ssh_conn_add_host/ssh_host_update/ssh_host_remove.

Returns:
    On success: `{'status': 'success', 'message', 'config_path', 'is_default'
    (bool), 'host_count'}`. On failure (path doesn't exist, path is a directory,
    or the file fails to parse as valid host config TOML):
    `{'status': 'error', 'error': <message>}` - not a raised exception.
- **ssh_host_list** — List all configured SSH hosts with their aliases and descriptions. This is the
ONLY correct way to see what hosts are configured - never read the host
configuration TOML file directly (ssh_conn_add_host's docstring names its path).
That file stores every host's password, sudo password, and key passphrase in
plaintext; this tool deliberately omits all of that and returns only the fields
below. If you need to add, change, or remove a host, use ssh_conn_add_host,
ssh_host_update, or ssh_host_remove instead of editing the file - between those
and ssh_host_use_config, there is no legitimate reason to open it.

Lists hosts from whichever config file is currently active - the server's
default unless ssh_host_use_config was called to switch to an alternate file.
The returned `config_path` always shows which one that is.

Returns:
    Dictionary with:
    - hosts: List of host information dictionaries, each containing:
      - key: The 'user@host' key
      - alias: Optional short name for the host
      - description: Optional description of the host
    - config_path: The host configuration file this list came from
- **ssh_host_remove** — Remove a host configuration from the host configuration TOML file (see
ssh_conn_add_host's docstring for its exact path and why you should never read it
directly - use ssh_host_list to see what's configured instead).

To change a host's password/port/etc. rather than deleting it, use
ssh_host_update instead - removing and re-adding loses every field you don't
explicitly resupply.

Returns:
    Dictionary with operation status
- **ssh_host_disconnect** — Disconnect the current SSH connection if one exists.

Use this when you want to explicitly close the current SSH connection
before connecting to a different host or when you're done with SSH operations.

Returns:
    Dictionary with disconnection status
- **ssh_conn_verify_sudo** — Check whether elevated access is available on the remote system, without running
any privileged command. Call this before using `use_sudo=True` on other tools if
you're not sure elevation will succeed.

On Linux/macOS: probes whether `sudo` is available and whether it needs a
password (via a passwordless `sudo -n` check, then a password-based check if
that fails). Tools called afterward with `use_sudo=True` will use whichever mode
this detected.

On Windows: there is no per-command elevation - checks whether the current SSH
session itself is already running as Administrator. If it's not, no tool call
can become elevated; you must reconnect as an Administrator account instead.

Returns:
    Dictionary with:
    - available (bool): True if sudo/elevation can be used at all (passwordless
      OR password-based on Linux/macOS; same as `passwordless` on Windows, since
      there's no separate password-based mode there)
    - passwordless (bool): True if no password is needed (passwordless sudo, or
      an already-elevated Windows session)
    - requires_password (bool): True if sudo works but needs a password (always
      False on Windows)
- **ssh_task_status** — Check the liveness of a background task by PID (from ssh_task_launch, or any other
real remote PID - e.g. the `pid` field from ssh_cmd_run). This does a live check on
the remote host every call, not a cached lookup, so it's safe to call repeatedly.

Returns:
    `{'pid', 'status', 'running' (bool, True iff status=='running'), 'timestamp'}`.
    `status` is one of:
    - 'running': the process currently exists.
    - 'exited': the process is gone - it either completed or was killed; there's
      no way to distinguish which, or recover its exit code, from this tool alone.
    - 'invalid': the given `pid` isn't a valid positive integer.
    - 'error': the liveness check itself failed (e.g. connection issue) - this
      does NOT mean the process exited, just that its status is unknown.
- **ssh_task_kill** — Terminate a background task (launched via ssh_task_launch, or any other real
remote PID) by sending a signal to its PID.

If force=True and the process doesn't exit after wait_seconds,
it will be forcibly killed with SIGKILL (signal 9).

With `use_sudo=True` on a 'linux'/'flex' connection with a BusyBox-style
`ps` that doesn't support `-o pgid=` (check `ssh_conn_connect`/
`ssh_conn_host_info`'s `capabilities`), this raises a clear error instead
of running - there is no clean fallback, since killing just the captured
PID (rather than its whole process group) can leave the sudo'd command's
real child process(es) running as orphans, exactly the failure mode this
check exists to prevent. Not an issue with `use_sudo=False`.

Returns:
    `{'pid', 'result', 'signal', 'force_kill_used', 'timestamp'}`. `result` is
    one of:
    - 'killed': confirmed terminated (by the initial signal or the force-kill
      fallback - see `force_kill_used` to tell which). Terminal - nothing left
      to check.
    - 'already_exited': the process was already gone before any signal was sent.
      Terminal.
    - 'failed_to_kill': still running after both the signal and force-kill
      attempt (or after the signal alone, if `force=False`). Not terminal - the
      process is still alive; consider retrying or investigating why it won't die.
    - 'invalid_pid': `pid` wasn't a valid positive integer - no signal was sent.
    - 'error': the kill attempt itself failed unexpectedly (e.g. connection
      issue) - the process's actual state is unknown, not necessarily still running.

    `force_kill_used` is True iff the SIGKILL fallback was actually attempted
    (the initial signal alone was not enough to end the process), regardless of
    whether the fallback itself succeeded - False if the initial signal alone
    was sufficient, the process was already gone, `pid` was invalid, or
    `force=False` so no fallback was ever attempted.
- **ssh_cmd_run** — Execute a command on the remote host and BLOCK until it completes, an io_timeout (silence),
a wait_timeout (elapsed cap), or a runtime_timeout (hard cap) occurs.

Timeout semantics (read this before choosing values):
- io_timeout and wait_timeout firing do NOT mean the remote command stopped - it genuinely
  keeps running. Monitoring is handed off to a background thread so output/exit code continue
  to be collected; the response has status='io_timeout'/'wait_timeout', still_running=true,
  and an id/pid you can use with ssh_cmd_check_status(handle_id=...) to poll again later, or
  ssh_cmd_output(handle_id=...) to read output collected so far (including output produced
  after this call returned). Do not rerun the command. You can also still decide to end it
  early with ssh_cmd_kill(handle_id=...) at any point after either of these fires.
- io_timeout fires only on SILENCE (no output for N seconds) - a command that keeps producing
  output never triggers it, however long it runs.
- wait_timeout fires after N seconds of TOTAL elapsed wait, regardless of activity - use this
  if you want to check in periodically on a long-running command even while it's actively
  producing output, rather than being blocked until completion.
- runtime_timeout is the only knob that DOES attempt to terminate the remote command - a hard
  safety ceiling, not a UX mechanism. Set it generously (much longer than the command should
  ever realistically take).
- For commands that should survive you disconnecting/reconnecting entirely (not just this
  call returning), use ssh_task_launch instead - it runs fully detached from this SSH session,
  whereas a command started here (even after surviving io_timeout/wait_timeout) is still tied
  to the current connection's lifetime.

You can access command history using 'ssh_cmd_history' to see previous commands and their output.
Commands run this way always appear there with origin='user' - pass include_internal=False to
ssh_cmd_history to hide unrelated internal plumbing from other tools (e.g. ssh_file_write's sudo dance).

Working directory: each call is an independent remote process (like a GitHub Actions step
or Ansible task, not a continuous shell) - nothing is remembered between calls, including
'cd'. Running ssh_cmd_run("cd /var/log") does NOT affect a later ssh_cmd_run("ls"); it will
still list the login directory. Use absolute paths, chain "cd dir && command" within one
call, or pass the cwd parameter to run this specific call in a specific directory
(Linux/macOS only for now).

Returns:
    Dictionary containing command output, status, and metadata. The handle
    identifier is returned here as `'id'`, but every other tool that accepts it
    (ssh_cmd_check_status, ssh_cmd_kill, ssh_cmd_output) names the same parameter
    `handle_id` - pass this value there. `output` (stdout) and `stderr` are always
    two SEPARATE fields, never interleaved into one combined stream - a command
    that succeeds can still have written to stderr (warnings, progress meters,
    non-fatal messages), so check `stderr` even on `status='success'`.

    `status` is one of:
    - 'success': command completed with exit code 0. `exit_code`, `output`, `stderr` populated.
    - 'command_failed': completed with a non-zero exit code. `exit_code`, `output`, `stderr` populated.
    - 'cwd_not_found': the `cwd` parameter didn't exist on the remote host - the
      command was NOT executed at all (fails closed).
    - 'io_timeout': no output within `io_timeout` seconds - remote command was NOT
      killed, still_running=true. Poll with ssh_cmd_check_status(handle_id=...).
    - 'wait_timeout': `wait_timeout` elapsed regardless of activity - remote command was
      NOT killed, still_running=true. Poll with ssh_cmd_check_status(handle_id=...).
    - 'runtime_timeout': `runtime_timeout` exceeded - an attempt was made to kill
      the remote command (see ssh_cmd_check_status's `'killed'` status to confirm).
    - 'sudo_required': `use_sudo=True` but elevation isn't available (see
      ssh_conn_verify_sudo before retrying).
    - 'busy': another ssh_cmd_run is already in flight on this connection - only
      one command can run at a time per connection.
    - 'error': unexpected failure (e.g. connection dropped).
- **ssh_cmd_kill** — Terminate a currently running command by its handle ID (the `id` field from
ssh_cmd_run's response).

This tool is specifically for killing commands started with ssh_cmd_run - it
looks up `handle_id` in this connection's command history, not by raw PID. For
background tasks launched with ssh_task_launch, use ssh_task_kill with the PID
instead. Raises an error if `handle_id` doesn't exist in history (e.g. from a
previous connection - handles don't survive reconnects) or has no associated PID.

If force=True and the process doesn't exit after wait_seconds,
it will be forcibly killed with SIGKILL (signal 9).

Returns:
    `{'handle_id', 'pid', 'result', 'signal', 'force_kill_used', 'timestamp'}`.
    `result` is one of:
    - 'not_running': the command was already confirmed not running before any
      signal was sent (checked first, via a live PID status check) - nothing to do.
    - 'killed': confirmed terminated (by the signal or the force-kill fallback -
      see `force_kill_used` to tell which).
    - 'already_exited': the process was gone by the time the signal landed.
    - 'failed_to_kill': still running after the signal (and force-kill, if
      `force=True`) - not terminal, the process is still alive.
    - 'invalid_pid': the command's tracked PID wasn't a valid positive integer.
    - 'error': the kill attempt itself failed unexpectedly - the process's real
      state is unknown.

    `force_kill_used` is True iff the SIGKILL fallback was actually attempted
    (the initial signal alone was not enough), regardless of whether the
    fallback itself succeeded - False if the initial signal alone was
    sufficient, the process was already gone, or `force=False`.

    Note: after a successful kill (`'killed'` or `'already_exited'`, or the early
    `'not_running'` case), a later `ssh_cmd_check_status` call for this same
    `handle_id` will report status `'killed'`.
- **ssh_cmd_check_status** — Wait for the specified duration, then check the status of a command started with
ssh_cmd_run. Call this repeatedly - it's designed to be polled - after
ssh_cmd_run returns status='io_timeout' or 'wait_timeout' (the remote command is
still genuinely running in both cases - background monitoring keeps collecting
its output/exit code), until you get a TERMINAL status below. Do not rerun the
original command while polling.

Terminal status values (nothing left to wait for, stop polling):
- 'completed': confirmed finished, exit_code is populated - including for commands
  that survived an io_timeout/wait_timeout, since background monitoring keeps
  watching for the real exit code.
- 'killed': the remote process was confirmed terminated (e.g. runtime_timeout killed it,
  or a prior ssh_cmd_kill call found it already gone) - exit_code is not known.
- 'completed_exit_code_unknown': rare fallback - monitoring stopped without a confirmed
  exit code (should only really happen from before background monitoring existed, or
  after an unexpected error) and a live check now confirms the remote process is no
  longer running. Only its output (via ssh_cmd_output) is available, not its exit code.

Non-terminal status values (keep polling):
- 'running': still being actively monitored, not yet finished.
- 'unknown_still_running': rare fallback (same caveat as 'completed_exit_code_unknown')
  where a live check confirms the remote command is still actually running. Not a
  failure - call this tool again to keep checking.

Other:
- 'not_found': the handle_id doesn't exist (may be from a previous connection - handles don't
  survive reconnects, but background task PIDs from ssh_task_launch do).
- 'unknown': the handle exists but its metadata is missing from history (rare, internal
  inconsistency) - treat like 'not_found'.

Fallback behavior: if `handle_id` doesn't match any ssh_cmd_run handle, this tool
also tries treating it as a raw background-task PID (same as ssh_task_status) and,
if that succeeds, returns `{'pid', 'status': 'running'/'exited'/'invalid'/'error',
'is_background_task': True, ...}` instead - a DIFFERENT status vocabulary than the
one above. Prefer ssh_task_status directly for PIDs to avoid ambiguity.

Returns:
    `{'handle_id', 'waited_seconds', 'status', 'exit_code', 'pid',
    'output_available', 'output_lines', 'timestamp'}`, plus `'next_step'` (guidance
    text) when `status` is non-terminal.
- **ssh_cmd_output** — Retrieve captured output from a command started with ssh_cmd_run, identified by
its handle_id. Useful after an io_timeout/wait_timeout to see progress so far -
including output produced after that call returned, since background monitoring
keeps collecting it - or any time to re-inspect an earlier command's output
without rerunning it.

stdout and stderr are captured in separate buffers, not interleaved - `stream`
picks which one to retrieve. ssh_cmd_run's own response only ever includes
`output` (stdout); to see stderr from a successful command (warnings, progress
meters, non-fatal messages - stderr on success is real output, not just for
failures), call this tool with `stream='stderr'`.

Raises an error if handle_id doesn't exist (e.g. from a previous connection -
handles don't survive reconnects).

Returns:
    A plain list of output lines from the selected stream (most recent `lines`,
    or the last 50 by default) - not a dict, no status/metadata. For total line
    counts and whether output was truncated, use ssh_cmd_history(include_output=True)
    instead (stdout only there).
- **ssh_cmd_clear_history** — Clear the command history for the current SSH connection.

Returns:
    Dictionary with operation status
- **ssh_cmd_history** — Retrieve command execution history with optional output snippets.

Returns:
    List of dictionaries containing command history, ordered from oldest to newest by default.
    Each entry contains:
    - id: Command handle ID
    - command: Executed command
    - exit_code: Exit status
    - start_time: Execution start timestamp
    - end_time: Execution end timestamp
    - origin: 'user' for a directly user-requested command, or an internal-plumbing label
      ('tool_internal', 'connection_probe', 'sudo_probe') for a helper command issued by another tool
    - parent_tool: Name of the MCP tool that triggered this command, when origin != 'user'
    - output: Stdout snippet (if include_output=True) - stderr is NOT included here, even for
      a failed command; see the separate 'stderr' field
    - stderr: Stderr snippet (if include_output=True) - often the more useful stream for a
      failed command; retrieved the same way ssh_cmd_output(stream='stderr') would
- **ssh_task_launch** — Launch a command in the background and return its PID immediately, without waiting for it
to complete.

Prefer this over ssh_cmd_run for commands that will take a long time or may be quiet for
extended periods: package installs, container/image pulls, large downloads, backups,
compilation. It avoids holding a blocking tool call open and the ambiguity of io_timeout -
the PID it returns survives reconnects and can be checked anytime with ssh_task_status(pid),
and stdout_log/stderr_log can be read with ssh_file_read while the task is still running.

Output is redirected to files (see stdout_log/stderr_log), not captured in memory - read the
log files to see progress or final output.

Returns:
    `{'command', 'pid', 'start_time', 'stdout_log', 'stderr_log'}`. `stdout_log`/
    `stderr_log` are `None` if `log_output=False`. If you didn't pass an explicit
    `stdout_log`/`stderr_log` yourself, the returned path reflects the actual
    default log location used - `/tmp/task-<pid>.log` on Linux/macOS,
    `C:\Windows\Temp\task-<pid>.log` on Windows.
- **ssh_dir_mkdir** — Create a directory on the remote system.

Parent-directory creation and `mode` behavior differ by platform/sudo:
- Linux/macOS, `use_sudo=False` (default): uses SFTP `mkdir`, which is NOT
  recursive - this FAILS if the parent directory doesn't already exist. `mode`
  applies to the created directory.
- Linux/macOS, `use_sudo=True`: uses `mkdir -p -m <mode>`, which DOES create
  any missing parent directories.
- Windows: always creates missing parent directories (`New-Item -Force`,
  regardless of `use_sudo`, which has no effect on Windows anyway). The `mode`
  parameter is IGNORED entirely on Windows - there's no equivalent to Unix
  octal permissions there.

Returns:
    `{'status': 'success', 'path', 'mode' (octal string - reflects the
    requested mode even on Windows, where it was actually ignored), 'message',
    'connection'}` on success. Raises an exception on failure (e.g. missing
    parent directory without sudo on Linux/macOS).
- **ssh_dir_remove** — Remove a directory on the remote system - this is the simple `rmdir`/`Remove-Item`
equivalent. There is NO dry-run/preview mode here (unlike ssh_dir_delete below) -
it acts immediately. If `recursive=False` (default) and the directory is not
empty, this RAISES an exception rather than returning an error dict - it does not
partially delete anything.

For a safer recursive delete with a preview step, use ssh_dir_delete instead,
which defaults to `dry_run=True` and returns a graceful `{'status': 'error', ...}`
on failure instead of raising.

Returns:
    `{'status': 'success', 'path', 'recursive', 'message', 'connection'}` on
    success. Raises an exception on failure (non-empty directory with
    `recursive=False`, path not found, permission denied, etc.) rather than
    returning an error dict.
- **ssh_dir_list_files_basic** — List the immediate contents of a directory (via SFTP - not recursive, no
metadata). For recursive listing with size/permissions/type/etc., or to filter
by filename pattern, use ssh_dir_list_advanced or ssh_dir_search_glob instead.

Returns:
    List of bare filenames (strings) directly inside `path` - not full paths,
    not recursive, and no indication of which entries are files vs. directories
    (use ssh_file_stat on an entry, or ssh_dir_list_advanced, if you need that).
- **ssh_file_stat** — Get status information about a file or directory (via SFTP stat - works the same
way on all platforms, no shell command involved).

On Windows, `mode`/`uid`/`gid` come from the SFTP subsystem's own cross-platform
attribute reporting, not real Windows ACLs/ownership - Windows has no equivalent
concept, so these values (e.g. `uid`/`gid` of `0`) are not meaningful there and
should not be relied on to reason about actual Windows permissions/ownership.
`type`/`size`/`atime`/`mtime` are unaffected and accurate on all platforms.

Returns:
    `{'exists': True, 'path', 'type' ('file'/'directory'/'symlink'/'unknown'),
    'mode' (octal string, e.g. "0o40755"), 'uid', 'gid', 'size' (bytes), 'atime',
    'mtime'}` when the path exists. `atime`/`mtime` are raw numeric Unix
    timestamps (seconds since epoch, as returned by SFTP), not formatted date
    strings - convert with `datetime.fromtimestamp()` if you need a readable date.
    If the path does NOT exist (or stat failed for another reason, e.g.
    permission denied), returns `{'exists': False, 'path', 'error'}` instead -
    this is a normal, non-exceptional return value, not a raised error.
- **ssh_file_read** — Read file contents directly via SFTP.

This tool reads raw bytes from the remote file using SFTP and decodes them
on the client side. Unlike command-based file reading (cat, Get-Content),
SFTP completely bypasses shell and console encoding issues.

**Why use this instead of ssh_cmd_run with cat/Get-Content?**
- Works correctly with Unicode on ALL platforms including Windows
- Bypasses Windows PowerShell's OEM code page encoding problem
- More efficient for binary-safe file transfer
- No shell escaping issues with special characters in content

Returns:
    Dictionary with:
    - success: True if file was read successfully
    - content: The file contents as a string
    - size: Number of bytes read
    - encoding: The encoding used to decode the content
- **ssh_file_find_lines_with_pattern** — Search for a pattern in a remote file and return matching lines with their line
numbers. Use this to find WHERE a pattern occurs before using
ssh_file_get_context_around_line to see surrounding lines, or
ssh_file_replace_line/ssh_file_delete_line_by_content to edit (those require an
exact, unique line match - this tool helps you find that exact line first).

Regex flavor differs by platform when `regex=True`: POSIX extended regex
(`grep -E`) on Linux/macOS - avoid PCRE-only syntax like `\d`, use `[0-9]` or
`[[:digit:]]` instead; Python's `re` module on Windows (matched locally after
an SFTP read, not via PowerShell - see below). When `regex=False` (default),
the pattern is matched as a literal fixed string on every platform.

On Windows, this reads the whole file via SFTP and matches locally in Python,
rather than shelling out to PowerShell/Select-String - matched line content
could otherwise come back corrupted for non-ASCII text, since Windows' console
encodes stdout in its OEM code page rather than UTF-8 (the same problem
ssh_file_read's SFTP approach avoids).

Returns:
    `{'total_matches': int, 'matches': [{'line_number': int, 'content': str}, ...]}`.
    No matches is not an error - `total_matches` is 0 and `matches` is `[]`. On
    failure (e.g. file not found, permission denied), an `'error'` key is present
    instead.
- **ssh_file_get_context_around_line** — Get lines before and after a line, to see it in context before editing. Like the
line-editing tools (ssh_file_replace_line and siblings), `match_line` must match
exactly one line in the file (whitespace-trimmed, literal text, not a pattern) -
use ssh_file_find_lines_with_pattern first if you're not sure the line is unique.

Reads the file via SFTP and matches locally (same as ssh_file_find_lines_with_pattern) -
Unicode/non-ASCII content is safe on all platforms, including Windows.

Returns:
    On a unique match: `{'match_found': True, 'match_line_number': int,
    'context_block': [{'line_number': int, 'content': str}, ...]}` (the matched
    line plus `context` lines before/after).
    If the line isn't found, or matches more than once: `{'match_found': False,
    'error': str}` - for a multi-match error, also includes `'matches'` (every
    matching line, so you can pick a more specific `match_line`).
- **ssh_file_replace_line** — Replace a line in a file with a new line. `match_line` must match EXACTLY ONE
line in the file (whitespace-trimmed, literal text - not a pattern); if it
matches zero lines or more than one, the operation fails with a descriptive
error rather than guessing which line you meant. Use
ssh_file_find_lines_with_pattern first if you're not sure the line is unique.

PARAMETERS:
* file_path: Path to the file to modify
* match_line: Exact line content to match and replace (whitespace-trimmed)
* new_line: New line to insert in place of the match
* use_sudo: Use sudo for the operation (default: false)
* force: Force operation even if file can't be read (sudo only) (default: false)

RETURNS:
On success: `{'success': True, 'lines_written': 1}` (or, in the rare edge case
where `new_line` is identical to the matched line, `{'success': True, 'message':
'No changes needed...'}` instead - nothing to write). On failure (match not
found, match not unique, file not found/unreadable): `{'success': False, 'error':
str}` - not a raised exception. Note: unlike some other file tools, this does NOT
return `file_path` in the response.

EXAMPLES:
Example 1: Replace a commented line with an active configuration
```json
{
  "file_path": "/etc/ssh/sshd_config",
  "match_line": "#ClientAliveInterval 0",
  "new_line": "ClientAliveInterval 300"
}
```

Note: To delete a line entirely, use the dedicated ssh_file_delete_line_by_content tool instead.
To replace/insert MULTIPLE lines in one call, use ssh_file_replace_line_multi instead.
- **ssh_file_replace_line_multi** — Replace a line in a file with one or more new lines (or delete it, with an empty
list). Use this instead of ssh_file_replace_line when you need to insert more
than one line, or delete a line without using the separate
ssh_file_delete_line_by_content tool.

`match_line` must match EXACTLY ONE line in the file (whitespace-trimmed, literal
text - not a pattern); if it matches zero lines or more than one, the operation
fails with a descriptive error rather than guessing. Use
ssh_file_find_lines_with_pattern first if you're not sure the line is unique.

PARAMETERS:
* file_path: Path to the file to modify
* match_line: Exact line content to match and replace (whitespace-trimmed)
* new_lines: List of new lines to insert in place of the match
  - To replace with multiple lines: use ["first line", "second line", ...]
  - To delete the line entirely: use [] (empty list)
  - To replace with an empty line: use [""]
* use_sudo: Use sudo for the operation (default: false)
* force: Force operation even if file can't be read (sudo only) (default: false)

RETURNS:
On success: `{'success': True, 'lines_written': <len(new_lines)>}` (or, in the
rare edge case where the result is byte-identical to the original file,
`{'success': True, 'message': 'No changes needed...'}` instead). On failure
(match not found, match not unique, file not found/unreadable): `{'success':
False, 'error': str}` - not a raised exception. Note: does NOT return `file_path`
in the response.

EXAMPLES:
Example 1: Replace a line with multiple lines
```json
{
  "file_path": "/etc/hosts",
  "match_line": "127.0.0.1 localhost",
  "new_lines": ["127.0.0.1 localhost", "127.0.0.1 myhost.local"]
}
```

Example 2: Delete a line entirely
```json
{
  "file_path": "/etc/nginx/nginx.conf",
  "match_line": "# server_tokens off;",
  "new_lines": []
}
```

Example 3: Replace with an empty line
```json
{
  "file_path": "/etc/ssh/sshd_config",
  "match_line": "PermitRootLogin yes",
  "new_lines": [""]
}
```
- **ssh_file_transfer** — Transfer a single FILE between the local machine (running this MCP server) and
the remote host, via SFTP. Both `local_path` and `remote_path` must be file
paths, not directories - for whole-directory transfers, use ssh_dir_transfer
instead. For remote-to-remote copies (no local machine involved), use
ssh_file_copy instead.

Caveat: `use_sudo=True` on download/upload stages a copy through `/tmp/` using
Unix shell commands (`mv`/`chmod`/`rm`) - this only works against Linux/macOS
remote hosts. Windows has no per-command sudo concept anyway (see
ssh_conn_verify_sudo), so `use_sudo=True` against a Windows connection raises
immediately instead of attempting these Unix commands - connect as
Administrator instead.

Returns:
    `{'operation' (human-readable description of what happened), 'success':
    True, 'local_path', 'remote_path', 'sudo', 'connection'}`. Raises an
    exception on failure rather than returning a `success: False` dict.
- **ssh_dir_transfer** — Transfer directories between local and remote systems.

Uses archive-based transfer for efficiency:
- Upload: Archives locally, transfers, extracts on remote
- Download: Archives on remote, transfers, extracts locally

Archive format is automatically selected based on remote OS:
- Linux/macOS: tar.gz
- Windows: zip
- **ssh_file_insert_lines_after_match** — Insert one or more new lines immediately after a matching line. `match_line`
must match EXACTLY ONE line in the file (whitespace-trimmed, literal text - not
a pattern); if it matches zero lines or more than one, the operation fails with
a descriptive error rather than guessing. Use ssh_file_find_lines_with_pattern
first if you're not sure the line is unique.

PARAMETERS:
* file_path: Path to the file to modify
* match_line: Exact line content to match (whitespace-trimmed)
* lines_to_insert: List of lines to insert after the match
  - To insert multiple lines: use ["first line", "second line", ...]
  - To insert a single line: use ["line to insert"]
  - To insert an empty line: use [""]
* use_sudo: Use sudo for the operation (default: false)
* force: Force operation even if file can't be read (sudo only) (default: false)

RETURNS:
On success: `{'success': True, 'lines_inserted': <len(lines_to_insert)>}` (note:
the key is `lines_inserted` here, vs. `lines_written` on
ssh_file_replace_line/ssh_file_replace_line_multi). On failure (match not found,
match not unique, file not found/unreadable): `{'success': False, 'error': str}`
- not a raised exception. Does NOT return `file_path` in the response.

EXAMPLES:
Example 1: Insert configuration lines after a marker
```json
{
  "file_path": "/etc/nginx/nginx.conf",
  "match_line": "http {",
  "lines_to_insert": ["    server_tokens off;", "    client_max_body_size 20M;"]
}
```

Example 2: Add a new host entry after localhost
```json
{
  "file_path": "/etc/hosts",
  "match_line": "127.0.0.1 localhost",
  "lines_to_insert": ["192.168.1.10 myserver.local"]
}
```
- **ssh_file_delete_line_by_content** — Delete a line by its exact content. `match_line` must match EXACTLY ONE line in
the file (whitespace-trimmed, literal text - not a pattern); if it matches zero
lines or more than one, the operation fails with a descriptive error rather than
guessing which line(s) to delete. Use ssh_file_find_lines_with_pattern first if
you're not sure the line is unique. (Equivalent to
ssh_file_replace_line_multi(new_lines=[]), provided as a clearer-named shortcut.)

Returns:
    On success: `{'success': True}` (no count field - only ever deletes the one
    matched line). On failure (match not found, match not unique, file not
    found/unreadable): `{'success': False, 'error': str}` - not a raised
    exception.
- **ssh_file_copy** — Copy a file on the remote host (source and destination are both remote paths -
for local<->remote transfers use ssh_file_transfer instead).

If `append_timestamp=True`, a timestamp is inserted before the destination's file
extension in the format `%Y%m%dT%H%M%S`, e.g. `destination_path="/etc/hosts.bak"`
becomes `/etc/hosts.20260704T153045.bak` - not appended after the extension, and
not configurable to a different format.

Returns:
    On success: `{'success': True, 'copied_to': <actual destination path used,
    including the timestamp if applied>}`. On failure (source not found,
    permission error): `{'success': False, 'error': str}` - not a raised
    exception.
- **ssh_file_write** — Create a new file, or overwrite/append to an existing one, with the given
content (works the same whether or not the file already exists - `append=True`
on a nonexistent file just creates it). Handles special characters and
multi-line content properly.

`mode` (Unix permission bits) is applied via a `chmod` command after writing -
this only works on Linux/macOS. On Windows there's no equivalent, so `mode` is
silently ignored there (same convention as `ssh_dir_mkdir`'s `mode` parameter).

Returns:
    On success: `{'success': True, 'file_path', 'bytes_written' (int), 'mode'
    (octal string, or `None` if not set OR if the connection is Windows - the
    response never echoes back a mode value that wasn't actually applied),
    'append' (bool, echoes the parameter), 'connection'}`. On failure (e.g.
    parent directory missing and `create_dirs=False`, write error):
    `{'success': False, 'file_path', 'error'}` - not a raised exception.
- **ssh_file_move** — Move or rename a file or directory (works for both - whichever `source` is).

If `destination` already exists: fails cleanly with `overwrite=False` (default);
with `overwrite=True`, it's replaced (`mv -f`/`Move-Item -Force`). If `source`
doesn't exist, also fails cleanly rather than raising.

Returns:
    `{'success': True, 'message': str}` on success, or `{'success': False,
    'message': str}` on failure (source not found, destination exists and
    `overwrite=False`, permission error) - not a raised exception.
- **ssh_dir_search_glob** — Recursively search for files (or directories, with `include_dirs=True`) matching
a filename glob pattern (e.g. `*.log`) - matches by NAME only, not content; use
ssh_dir_search_files_content instead to search inside files. For metadata beyond
just path/type (size, mtime, permissions), use ssh_dir_list_advanced instead.

`max_depth` uses standard `find -maxdepth` semantics: this is the same on both
Linux/macOS and Windows despite the different underlying commands. `max_depth=1`
means the given `path` itself plus its immediate children only (not
grandchildren); omit `max_depth` for unlimited recursion.

On a 'linux'/'flex' connection with a limited BusyBox-style `find` (no
`-printf` - check `ssh_conn_connect`/`ssh_conn_host_info`'s `capabilities`),
this raises a clear error instead of running. Fallback: `ssh_dir_list_files_basic`
(non-recursive filenames, match the pattern yourself) - SFTP-based and
unaffected by this, at the cost of one call per directory level.

Returns:
    List of `{'path': str, 'type': str}` - `type` is a single-character code
    (`f`=file, `d`=directory, `l`=symlink) from the underlying `find`/`stat`
    output, not a spelled-out word.
- **ssh_dir_calc_size** — Calculate the total size of a directory recursively (sum of all file sizes
under it).

On a 'linux'/'flex' connection with a BusyBox-style `du` that doesn't
support `-s`+`-b` combined (check `ssh_conn_connect`/`ssh_conn_host_info`'s
`capabilities`), this raises a clear error instead of running. Fallback:
`ssh_cmd_run("du -sk <path>")` - kilobytes instead of exact bytes, but
works on most BusyBox `du` builds even when `-sb` doesn't.

Returns:
    `{'path', 'size_bytes' (int), 'size_human' (e.g. "1.23 MB", "512.00 KB",
    "3.50 GB" - 2 decimal places, binary/1024-based units)}`.
- **ssh_dir_delete** — Delete a directory and all its contents recursively. `dry_run` DEFAULTS TO
`True` - calling this with no arguments other than `path` only PREVIEWS what
would be deleted and deletes NOTHING; you must explicitly pass `dry_run=False`
to actually delete. Refuses to delete a small set of critical paths outright
(root, home directory, `C:\Windows`, `C:\Users`, etc.) regardless of `dry_run`.

For a simpler non-recursive removal without the preview step, see ssh_dir_remove.

Returns:
    `{'status': 'success'/'error', 'deleted_items': [str, ...] (paths that
    were/would be removed, depth-first order)}`, plus `'dry_run': True` ONLY when
    this was a preview (check for this key to tell a preview apart from a real
    deletion - it's absent, not `False`, on actual deletions) and `'error'`
    (string) on failure. Also fails (with `'error'` set) if `path` is a
    recognized critical directory.
- **ssh_dir_batch_delete_files** — Recursively find and delete files matching a filename glob pattern (same pattern
syntax as ssh_dir_search_glob, e.g. `*.tmp`) under a directory. `dry_run`
DEFAULTS TO `True` - calling this with no arguments other than `path`/`pattern`
only PREVIEWS which files would be deleted and deletes NOTHING; you must
explicitly pass `dry_run=False` to actually delete.

On a 'linux'/'flex' connection with a BusyBox-style `xargs` that doesn't
support `-0` (check `ssh_conn_connect`/`ssh_conn_host_info`'s `capabilities`),
this raises a clear error instead of running. Fallback: `ssh_cmd_run` with
`find <path> -name '<pattern>' -exec rm -f {} +` instead - portable, and
still safe with spaces in filenames.

Returns:
    `{'status': 'success'/'error', 'deleted_files': [str, ...] (matching file
    paths that were/would be deleted)}`, plus `'dry_run': True` ONLY when this
    was a preview (check for this key to tell a preview apart from a real
    deletion) and `'error'` (string) on failure. Note the key is `deleted_files`
    here, vs. `deleted_items` on ssh_dir_delete.
- **ssh_dir_list_advanced** — List contents of a directory recursively with full metadata (size, permissions,
ownership, modification time) - use this instead of ssh_dir_list_files_basic
(which only returns bare filenames, non-recursive) when you need more than just
names, or ssh_dir_search_glob when you only need to filter by filename pattern
without full metadata (that tool is also faster for large trees).

`max_depth` uses standard `find -maxdepth` semantics: `max_depth=1` means `path`
itself plus its immediate children only; omit for unlimited recursion.

On a 'linux'/'flex' connection with a limited BusyBox-style `find` (no
`-printf` - check `ssh_conn_connect`/`ssh_conn_host_info`'s `capabilities`),
this raises a clear error instead of running. Fallback: `ssh_dir_list_files_basic`
(non-recursive filenames) plus `ssh_file_stat` per entry for metadata - both are
SFTP-based and unaffected by this, at the cost of one call per directory level.

Returns:
    List of `{'path', 'type', 'size_bytes' (int), 'modified_time' (float Unix
    timestamp), 'permissions' (string, e.g. "755"), 'user', 'group'}`. `type` is
    a spelled-out word here ('file'/'directory'/'symlink'/'pipe'/'socket'/
    'block'/'character') - note this differs from ssh_dir_search_glob, which
    returns single-character type codes ('f'/'d'/'l') for the same concept.
    On Windows, `permissions` is always the literal placeholder `"0"` (Windows
    has no Unix-style permission bits) and `group` is always `"unknown"` -
    `user` still reflects the real file owner there.
- **ssh_dir_search_files_content** — Recursively search file CONTENTS for a pattern under a directory (unlike
ssh_dir_search_glob/ssh_file_find_lines_with_pattern, which match filenames or
search within a single already-known file respectively).

Regex flavor differs by platform when `regex=True`: POSIX extended regex
(`grep -E`) on Linux/macOS - avoid PCRE-only syntax like `\d`, use `[0-9]` or
`[[:digit:]]` instead; Python's `re` module on Windows (matched locally after
an SFTP read per file, not via PowerShell - see below). When `regex=False`
(default), the pattern is matched as a literal fixed string.

On Windows, filenames are enumerated via PowerShell but each file's content is
read via SFTP and matched locally in Python, rather than piping matched line
content back through PowerShell/Select-String - that content could otherwise
come back corrupted for non-ASCII text, since Windows' console encodes stdout
in its OEM code page rather than UTF-8 (the same problem ssh_file_read's SFTP
approach avoids).

On a 'linux'/'flex' connection with a BusyBox-style `xargs` that doesn't
support `-0` (check `ssh_conn_connect`/`ssh_conn_host_info`'s `capabilities`),
this raises a clear error instead of running. Fallback: `ssh_cmd_run` with
`find <dir_path> -type f -exec grep -l '<pattern>' {} +` instead - portable,
and still safe with spaces in filenames.

Returns:
    List of `{'file': str, 'line': int, 'content': str}` - one entry per matching
    line, across all files under `dir_path`. Empty list if nothing matches (not
    an error).
- **ssh_dir_copy** — Copy a directory recursively (remote-to-remote; for local<->remote use
ssh_dir_transfer instead).

`overwrite` controls what happens if `destination_path` already exists:
- `True`: the entire existing destination directory is deleted first, then a
  fresh copy is made.
- `False` (default): does NOT block the copy - it copies into the existing
  destination as-is, merging with whatever's already there, and any file that
  shares a name with a source file gets silently overwritten anyway. This does
  NOT behave like the "fail if destination exists" semantics of
  ssh_file_move/ssh_file_copy's own `overwrite` parameter.

Returns:
    `{'status': 'success'/'error', 'files_copied' (int), 'bytes_copied' (int),
    'destination_path'}` (plus `'message'` on error). Note: `files_copied`/
    `bytes_copied` are computed by counting the destination directory's TOTAL
    contents *after* the copy, not just the files newly copied in this call - if
    merging into a non-empty destination, these counts include pre-existing files too.
- **ssh_archive_create** — Create a compressed archive from a directory.

IMPORTANT cross-platform caveat: on Windows, `tar.gz`/`tar` are NOT natively
available - requesting either one silently creates a `.zip` file instead (via
`Compress-Archive`), and `archive_path`'s extension is auto-corrected to `.zip`
if needed. The only way to tell this happened is the returned `format` field
saying `'zip'` even though you asked for `tar.gz`/`tar`. An archive created this
way on Windows can only be extracted with ssh_archive_extract on another Windows
host - Linux/macOS's extractor only recognizes `.tar`/`.tar.gz`/`.tgz` files, not
`.zip`. There is no cross-platform-portable archive format currently available
through this tool.

Returns:
    On success: `{'status': 'success', 'success': True, 'archive_created' (the
    actual path used, which may differ from `archive_path` if the extension was
    corrected on Windows), 'format' (the ACTUAL format used - 'tar.gz'/'tar' on
    Linux/macOS, always 'zip' on Windows), 'size_bytes'}`. On failure:
    `{'status': 'error', 'message': str}` - not a raised exception.
- **ssh_archive_extract** — Extract an archive to a directory. The accepted format is platform-specific and
determined purely by `archive_path`'s file extension (not by inspecting the
archive's actual content):
- Linux/macOS: `.tar.gz`, `.tgz`, or `.tar` only. A `.zip` file (e.g. created by
  ssh_archive_create on Windows) will fail here with an unsupported-format error.
- Windows: `.zip` only. A `.tar`/`.tar.gz` file (e.g. created by
  ssh_archive_create on Linux/macOS) will fail here the same way.
There is no cross-platform-portable archive format currently available through
this tool - archives must be created and extracted on the same OS family.

`overwrite=False` (default) does not fail outright if some files already exist at
the destination - on Linux/macOS it extracts everything else and just logs a
warning about the skipped files (no per-file detail returned); on Windows the
behavior follows `Expand-Archive`'s own overwrite handling.

On a 'linux'/'flex' connection with a BusyBox-style `tar` (check
`ssh_conn_connect`/`ssh_conn_host_info`'s `capabilities`), this can raise a
clear error instead of running - unlike the wrong-format/extraction-error
cases below, which return an error dict, this is a raised exception. Two
independent gaps: missing `--strip-components` blocks extraction entirely
(no clean single-tool fallback - extract without stripping via `ssh_cmd_run`,
the archive's own top-level directory will remain, then relocate its
contents up one level yourself); missing `--keep-old-files` only blocks when
`overwrite` isn't explicitly `True` - passing `overwrite=True` avoids that
one specifically.

Returns:
    On success: `{'status': 'success', 'success': True, 'extracted_files': [str,
    ...] (paths as listed inside the archive), 'destination_path'}`. On failure
    (wrong format for this platform, extraction error): `{'status': 'error',
    'message': str, 'extracted_files': []}` - not a raised exception.

## Claude Desktop Quick Installation
Install path detected from listing signals. Uses `uvx` (confidence: high):

```json
"mcpServers": {
  "cygnus-ssh-mcp": {
    "command": "uvx",
    "args": ["cygnus-ssh-mcp"]
  }
}
```

## Documentation

## What cygnussystems/cygnus-ssh-mcp MCP server does

The cygnussystems/cygnus-ssh-mcp MCP server exposes 46 purpose-built tools for administering machines reachable through SSH. It supports Linux, macOS, and Windows targets, along with a `flex` platform type for devices such as routers, NAS systems, and other hosts with non-standard or reduced shells.

Instead of relying on one generic shell tool, the server separates common administration tasks into operations for commands, files, processes, directories, archives, and host management. An MCP-compatible client can use those operations to inspect a host, update a remote configuration file, transfer and extract an artifact, or start work that continues after the initial request.

## How it works

The cygnussystems/cygnus-ssh-mcp MCP server runs locally and opens standard SSH connections to hosts defined in a TOML file. Each entry identifies a user and host, plus a port and either a password or private-key file. Optional aliases let clients refer to a machine by a shorter name. A configured sudo password enables privileged operations on Linux and macOS; the regular login password can be reused when applicable.

File content is handled through SFTP where appropriate. This avoids relying on a target shell's console encoding and is intended to preserve non-ASCII text on Windows systems. Background-task tools track the remote process, provide output later, and support termination. The implementation also accounts for platform-specific process identifiers and exit-code behavior.

The target machine must already run an SSH service. The MCP server supplies the management interface, not the SSH daemon itself.

## Setup and configuration

Install the package with pip or launch it without a persistent installation through `uvx cygnus-ssh-mcp`. On its first start, it creates `~/.mcp_ssh_hosts.toml` with restrictive permissions when no host file exists. If that path is absent, it can use `./mcp_ssh_hosts.toml` instead. Hosts may also be added through the `ssh_conn_add_host` tool.

A host entry requires a port and either `password` or `keyfile`. Supported optional settings include `alias`, `description`, `sudo_password`, and `key_passphrase`. Key paths can use the home-directory shorthand shown in the project documentation. When key authentication is used without a login password, privileged commands require a separate sudo password or passwordless sudo on the target.

Linux and macOS hosts need SSH enabled. Windows targets require OpenSSH Server, with the README documenting Windows Server 2019+, Windows 10, and Windows 11 as supported environments. Windows Server 2016 and other edge cases have separate guidance.

## Tools and capabilities

The cygnussystems/cygnus-ssh-mcp MCP server includes tools for:

- Running remote commands and retaining command history with output.
- Executing commands with sudo where the host configuration permits it.
- Editing individual file lines without a download, local edit, and re-upload cycle.
- Creating, monitoring, and stopping background tasks.
- Listing, creating, moving, copying, and removing directories recursively.
- Creating and extracting archives.
- Reading and writing files through SFTP for Unicode-safe handling.
- Connecting to configured hosts by alias.
- Inspecting processes on Windows SSH targets.

These capabilities fit tasks such as checking system resources and logs, patching configuration files, deploying an archive, restarting a service, or retrieving output from a long-running command.

## Limitations and notes

SSH must be installed and running on every target, and the configured account must have the permissions required for requested operations. Sudo support is described for Linux and macOS; Windows administration follows the permissions available to the SSH account.

`flex` support is still under development. It has been tested with several alternate devices, but behavior may vary across routers, NAS products, embedded systems, and other shells that differ from standard Linux, macOS, or Windows environments.

The project is distributed under GPL version 3. The README identifies Claude Desktop, Claude Code, OpenCode, and other MCP-compatible clients as usable clients; this listing specifically records Claude Desktop among the supported client slugs.

_Full upstream README: https://allmcps.com/mcp/cygnussystems-cygnus-ssh-mcp/readme_

