Skip to main content
Glama

linuxmcp

An MCP server that gives an AI agent shell access to a fixed, pre-registered set of Linux machines over SSH, and returns structured results.

The design point is the trust boundary: exec_command never takes a host, port or credential inline. It takes a machine_id that must already resolve to an entry in the registry. Growing that registry is its own explicit, audited tool call, never a side effect of running a command.

  • Transport: stdio only. It runs as a local subprocess launched by Claude Desktop / Claude Code and is not exposed over the network.

  • Audit: every tool call is appended to ~/.local/state/linuxmcp/audit.jsonl.

  • Guardrails: server-enforced timeouts that kill the remote process group, output truncation with an explicit truncated flag, optional per-machine deny-pattern regexes, and connection verification before a machine can enter the registry.

Install

pip install -e .
# with the test dependencies
pip install -e ".[dev]"

Python 3.11+. Works against both the 1.x (FastMCP) and 2.x (MCPServer) MCP Python SDKs; both are exercised in development.

This installs a linuxmcp console script. Validate a config without serving. This is useful before restarting a client, since a bad registry makes the server exit rather than start:

linuxmcp --check --config ~/.config/linuxmcp/machines.yaml

Related MCP server: ssh-mcp

Configure

Copy examples/machines.yaml to ~/.config/linuxmcp/machines.yaml:

machines:
  - id: web01
    host: 192.168.1.10
    port: 22
    user: admin
    auth: key                      # key | agent
    key_path: ~/.ssh/id_ed25519
    tags: [prod, web]
    description: "Primary nginx frontend"
    deny_patterns:
      - 'rm\s+-rf\s+/(\s|$)'
      - '\bmkfs\b'
      - 'dd\s+if=.*of=/dev/'

  - id: db01
    host: 10.0.0.5
    port: 22
    user: postgres
    auth: agent                    # use the running ssh-agent, no key_path needed
    tags: [prod, db]
    description: "Postgres primary"

  - id: switch01
    host: 10.0.0.2
    user: admin
    auth: password
    password_env: LINUXMCP_SWITCH01_PASSWORD   # variable NAME, never the password
    tags: [net]

defaults:
  connect_timeout_sec: 10
  command_timeout_sec: 60
  max_output_bytes: 200000
  idle_timeout_sec: 300
  max_pool_size: 16
  kill_grace_sec: 5
  max_transfer_bytes: 20000000
  strict_host_keys: true
  env_file: ~/.config/linuxmcp/secrets.env

Secrets. The YAML holds key paths, the string agent, or an environment variable name. It never holds a password or private key material. Key files must exist and should be 0600; loose permissions are a warning at startup and a hard rejection when adding or updating a machine. Encrypted keys are not supported directly, so add them to ssh-agent and use auth: agent.

Password auth

Prefer keys. Some hosts genuinely can't do key auth (appliances, switches, a box you don't control), so auth: password exists for those. The registry stores the name of an environment variable, and the server reads it at connect time:

  - id: switch01
    host: 10.0.0.2
    user: admin
    auth: password
    password_env: LINUXMCP_SWITCH01_PASSWORD

Supply the value either from the server process environment, or from an env file that defaults.env_file points at:

# ~/.config/linuxmcp/secrets.env   (chmod 0600)
LINUXMCP_SWITCH01_PASSWORD=correct horse battery staple

See examples/secrets.env. Details worth knowing:

  • The env file must be 0600 or the server refuses to start. That's stricter than the warning you get for a loose key file, because this one is plaintext passwords.

  • A process env var beats the file, so you can override one password for a single run without editing anything.

  • The file is read at connect time, not cached at startup, so rotating a password takes effect on the next connection with no restart. There's a test for exactly that.

  • Everything after the first = is the value. No inline-comment stripping, no shell interpolation. A password containing #, spaces, or another = survives intact. Quote the value to keep leading or trailing whitespace.

  • A missing password warns at startup instead of aborting, unlike other config errors. One appliance's absent secret shouldn't take the rest of the fleet offline. The connection attempt itself then fails with a message naming the variable.

  • asyncssh sends the password over keyboard-interactive too when the server offers only that, which is the common PAM setup. Both paths are covered by integration tests against real servers.

The password never reaches the registry file, the audit log, a tool response, or the agent. Even the variable name stays out of tool output, same as key_path. The server's instructions tell the model not to ask you for a password or accept one as a tool argument; if a connection fails, it should name the variable for you to set.

Password mode disables public key and agent auth for that machine. Otherwise a stale default key gets offered first and a wrong password comes back as a confusing key error.

Host keys. Host key verification is on by default against ~/.ssh/known_hosts. Point defaults.known_hosts (or a machine's known_hosts) at another file, or set it to the literal none to disable verification for that machine. defaults.strict_host_keys: false disables it fleet-wide. Disabling is logged loudly at startup.

Fail closed. Any validation error at startup aborts the server rather than skipping the bad entry. That covers an unknown field, a duplicate id, a missing key file, and an invalid deny-pattern regex.

Persistence. add_machine / remove_machine / update_machine rewrite machines.yaml under an exclusive flock on machines.yaml.lock, via a same-directory temp file and rename, so a crash mid-write cannot leave a half-written registry and two concurrent calls cannot clobber each other. Rewrites normalise the document: YAML comments are not preserved across a mutation.

Register with Claude Desktop / Claude Code

Add to claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/claude_desktop_config.json; Linux: ~/.config/Claude/claude_desktop_config.json), or to .mcp.json in a project for Claude Code:

{
  "mcpServers": {
    "linuxmcp": {
      "command": "/absolute/path/to/venv/bin/linuxmcp",
      "args": [
        "--config", "/home/you/.config/linuxmcp/machines.yaml",
        "--audit-log", "/home/you/.local/state/linuxmcp/audit.jsonl"
      ],
      "env": {
        "SSH_AUTH_SOCK": "/run/user/1000/keyring/ssh"
      }
    }
  }
}

Both args are optional; those are the defaults. Use the absolute path to the console script; a bare linuxmcp only resolves if the launching process inherits the right PATH.

SSH_AUTH_SOCK is only needed for machines with auth: agent. GUI-launched Claude Desktop does not inherit your shell environment, so an agent-auth machine will fail with a clear error unless you set it here explicitly. The same applies to password variables: if you supply them from your shell rather than an env file, a GUI-launched client won't see them. Use defaults.env_file and you don't have to think about it.

Or, in Claude Code:

claude mcp add linuxmcp -- /absolute/path/to/venv/bin/linuxmcp

Tools

Tool

Blast radius

list_machines()

Read-only. Ids, tags, description, cached reachability. No auth details.

get_machine_status(machine_id)

Read-only. Connects and authenticates, runs nothing.

exec_command(machine_id, command, timeout_sec?, cwd?)

Runs a shell command on one host.

exec_command_multi(machine_ids, command, timeout_sec?)

Same command, several hosts, concurrent.

add_machine(...)

Trust-expanding: widens the reachable fleet. Persists.

remove_machine(machine_id)

Removes an entry and closes its pooled session. Persists.

update_machine(machine_id, ...)

Partial update, same validation as add_machine. Persists.

upload_file / download_file

SFTP, explicit path pairs, size-limited.

exec_command returns:

{
  "stdout": "...", "stderr": "...", "exit_code": 0,
  "truncated": false, "duration_ms": 143, "timed_out": false,
  "stdout_bytes": 812, "stderr_bytes": 0
}

A non-zero exit_code is a normal result, not a tool error. truncated: true means output exceeded max_output_bytes; stdout_bytes / stderr_bytes report the true sizes.

The server sets MCP instructions telling the agent to call list_machines first, to confirm the target and the exact command before anything destructive, and to treat the three registry-mutation tools with the same care.

Safety model

  • Timeouts are enforced, not documented. The command is wrapped in the remote host's timeout -k, which puts it in its own process group and signals the whole group at the deadline, so pipelines and spawned children die instead of being orphaned. A client-side deadline sits behind that for hosts without coreutils timeout.

  • Output is capped but still drained. Past max_output_bytes the remainder is read and discarded rather than left unread. Abandoning the stream would stall the SSH window and hang a chatty command instead of letting it finish.

  • Passwords are referenced, not stored. auth: password holds an environment variable name; the value is resolved at connect time and never written to the registry, the audit log, or a tool response. The env file that can hold it must be 0600.

  • Deny patterns are regexes checked before any connection is made, so a denied command never reaches the host. This is a backstop for catastrophic commands (rm -rf /, mkfs, dd of=/dev/...), not a security boundary. The tool grants shell access by design, and any allow-list would be trivially bypassable from inside a shell. The boundary is the machine list.

  • Verification before registration. add_machine and update_machine open a real SSH connection by default and fail the call if it doesn't work, so a typo or a rotated key surfaces immediately instead of sitting in the registry until someone needs it. verify=false is there for machines that aren't up yet.

  • Connection reuse. A small LRU pool of live SSH sessions with idle-timeout eviction; a session is dropped when the machine's connection settings change or the machine is removed.

  • Audit log. One JSON object per line: timestamp, event, machine, command, exit code, duration, truncation flag, and a SHA-256 of the output. Command text is logged verbatim, which is the point. Command output is not, only its size and digest, so the log doesn't become a second copy of whatever a command happened to print.

Tests

pytest                      # everything
pytest -m "not integration" # unit tests only, no sockets
pytest -m integration       # only the real-SSH tests

Unit tests cover config validation and fail-closed behaviour, atomic/locked persistence, deny-pattern matching, audit-log contents, password resolution and redaction, and, against a mocked SSH layer, output truncation, timeout enforcement, remote command wrapping, and connection pooling.

The integration tests stand up a real asyncssh server on 127.0.0.1 on an ephemeral port and run real commands through a real /bin/sh. They're what actually proves the timeout wrapper fires, that it takes backgrounded children down with it instead of orphaning them, that SFTP size limits reject before any bytes move, and that password auth works against both a password-only and a keyboard-interactive-only server.

Not in scope

  • No network transport. stdio only, single local user. No SSE/HTTP server, no bind address, nothing listening.

  • No multi-user auth or permissions model. This is a personal admin tool. Anyone who can talk to this process can run anything the configured SSH users can run; there are no per-caller identities, roles, or scoped grants.

  • No command allow-list. Intentional; see the deny-pattern note above.

  • No secret management. Key discovery is your ~/.ssh and your agent, and linuxmcp stores paths. Passwords are referenced by env var name; there's no keyring, vault, or pass integration. If you want one, export the variable from your own wrapper before launching the server.

  • No interactive commands. stdin is closed on the remote side, so anything that prompts fails rather than hangs. Use the non-interactive flags.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Enables AI assistants to securely execute remote SSH commands, perform file transfers, and monitor system status through a standardized interface. It features robust security controls including command whitelisting, blacklisting, and credential isolation to prevent unauthorized operations.
    10
    22 npm
    MIT
  • A
    license
    A
    quality
    A
    maintenance
    Enables AI assistants to execute shell commands and transfer files via SFTP across remote servers using existing SSH configurations. It supports parallel execution on server groups and provides built-in safety warnings for potentially destructive commands.
    6
    2
    Mozilla Public 2.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI agents to securely execute commands on remote hosts via SSH and SFTP, with persistent shells, file transfers, screenshots, and an audit log.
    1
    MIT