Skip to main content
Glama
README.md
# 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

```bash
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:

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

## Configure

Copy [`examples/machines.yaml`](examples/machines.yaml) to `~/.config/linuxmcp/machines.yaml`:

```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:

```yaml
  - 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:

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

See [`examples/secrets.env`](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:

```json
{
  "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:

```bash
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:

```json
{
  "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

```bash
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.