Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,13 @@ Files ≤300 LOC. One responsibility per module (SRP). Split when a module grows

## MCP tool design

**Constant, heavily-parametrized surface — 3 resource-oriented tools.** Group by resource; push composition into params (`action`, `cursor`, `limit`, `recursive`, `timeout`, `bg`) — do **NOT** add more tools. New capability = a new param or `action` on an existing tool, almost always.
**Constant, heavily-parametrized surface — 3 resource-oriented tools.** Group by resource; push composition into params (`action`, `cursor`, `limit`, `recursive`, `timeout`, `bg`, `interactive`) — do **NOT** add more tools. New capability = a new param or `action` on an existing tool, almost always.

Current tools (three, constant):

| Tool | Params | Does |
|---|---|---|
| `bash` | `cmd`, `cwd?`, `timeout?`, `bg?` | run a command; inline if fast, else a job id (`bg` backgrounds at once) |
| `bash` | `cmd`, `cwd?`, `timeout?`, `bg?`, `interactive?` | run a command; inline if fast, else a job id (`bg` backgrounds at once; `interactive` sources `~/.bashrc` via `bash -ic` for aliases/version managers, default fast `sh -c`) |
| `job` | `action`, `id?`, `cursor?`, `limit?` | jobs by `action`: `poll` (paginated output), `list` (jobs + status), `kill` |
| `file` | `action`, `path?`, `content?`, `pattern?`, `recursive?`, `src?`, `dest?`, `cursor?`, `limit?` | file ops by `action`: `read`/`write`/`append`/`delete`/`list`/`grep`/`move` |

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcp-ssh"
version = "1.0.0"
version = "1.1.0"
edition = "2024"
rust-version = "1.85"
description = "Remote shell + file access for AI agents over authenticated MCP-HTTP — an ssh replacement you talk to over /mcp"
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ Want to go further? Run `bash("claude -p 'fix the failing test and push'")` —

## ⚡ Quickstart

**One-liner (Debian/Ubuntu)** — downloads the latest release, asks for a username + password, installs the service, and starts it:

```bash
curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh | sudo bash
```

<details><summary>Or do it by hand</summary>

```bash
# 1. install (Debian/Ubuntu — grab the .deb from releases)
sudo dpkg -i mcp-ssh_*.deb
Expand All @@ -28,9 +36,14 @@ mcp-ssh set-auth admin
# 3. start it as a systemd service
sudo systemctl enable --now mcp-ssh

# 4. put TLS in front (see docs/deploy.md) and connect from Claude
# 4. verify it's up on loopback
curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server
```

</details>

Then put TLS in front (see [docs/deploy.md](docs/deploy.md)) and connect from Claude.

mcp-ssh now listens on `127.0.0.1:1337` at `/mcp`. Expose it as `https://your-host/mcp` with a reverse proxy → **[docs/deploy.md](docs/deploy.md)**.

## 🧰 The tools
Expand Down Expand Up @@ -86,6 +99,7 @@ mcp-ssh set-auth <user> # configure the username/password

| Method | How |
|---|---|
| **One-liner** | `curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh \| sudo bash` — latest release, prompts for creds, installs + starts the service |
| **Debian/Ubuntu** | download `mcp-ssh_*.deb` from [releases](https://github.com/developerz-ai/mcp-ssh/releases) → `sudo dpkg -i mcp-ssh_*.deb` |
| **Docker** | pull the image and run it (see [docs/deploy.md](docs/deploy.md)) |
| **From source** | `cargo build --release` → binary at `target/release/mcp-ssh` |
Expand Down
134 changes: 134 additions & 0 deletions deploy/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# mcp-ssh one-shot installer.
#
# curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh | sudo bash
#
# Downloads the latest .deb from GitHub releases, installs it, prompts for the
# username + password, runs it as a systemd service, and prints how to verify.
# Re-runnable: it just updates the binary and re-applies your settings.
set -euo pipefail

REPO="developerz-ai/mcp-ssh"
CONF_DIR="/etc/mcp-ssh"
CONF="$CONF_DIR/config.toml"
ENV_FILE="$CONF_DIR/mcp-ssh.env"
OVERRIDE_DIR="/etc/systemd/system/mcp-ssh.service.d"

die() { echo "error: $*" >&2; exit 1; }

[ "$(id -u)" -eq 0 ] || die "run as root (use: curl -fsSL … | sudo bash)"

# Prompts must read from the terminal, not the curl pipe feeding our stdin.
TTY=/dev/tty
ask() { local v; printf '%s' "$1" >"$TTY"; read -r v <"$TTY"; printf '%s' "$v"; }
ask_secret(){ local v; printf '%s' "$1" >"$TTY"; read -rs v <"$TTY"; printf '\n' >"$TTY"; printf '%s' "$v"; }

# Escape a value for a double-quoted TOML string so a quote, backslash, or
# newline in the username/password can't corrupt or alter config.toml.
toml_escape() {
local s=$1
s=${s//\\/\\\\}
s=${s//\"/\\\"}
s=${s//$'\n'/\\n}
s=${s//$'\r'/\\r}
printf '%s' "$s"
}

echo "==> mcp-ssh installer"

# 1. Download the latest release .deb for this architecture.
ARCH="$(dpkg --print-architecture)" # amd64 / arm64
echo "==> Fetching latest release for $ARCH …"
API="https://api.github.com/repos/$REPO/releases/latest"
URL="$(curl -fsSL "$API" | grep -oE "https://[^\"]*_${ARCH}\.deb" | head -n1)" \
|| die "could not query GitHub releases"
[ -n "$URL" ] || die "no .deb asset for $ARCH in the latest release"

DEB="$(mktemp --suffix=.deb)"
trap 'rm -f "$DEB"' EXIT
echo "==> Downloading $(basename "$URL") …"
curl -fSL --progress-bar "$URL" -o "$DEB"

# 2. Install (apt resolves the .deb's own dependencies).
echo "==> Installing the package …"
apt-get install -y "$DEB" >/dev/null 2>&1 || { dpkg -i "$DEB" || true; apt-get -fy install; }

# 3. Credentials.
echo "==> Set the MCP login (one username + password)"
USER_NAME="$(ask 'Username: ')"; [ -n "$USER_NAME" ] || die "username required"
while :; do
PASS1="$(ask_secret 'Password: ')"; [ -n "$PASS1" ] || { echo " password required" >"$TTY"; continue; }
PASS2="$(ask_secret 'Confirm password: ')"
[ "$PASS1" = "$PASS2" ] && break || echo " passwords did not match — try again" >"$TTY"
done

# 4. Which OS user the agent's shell runs as (its ~/.bashrc/aliases/version
# managers are what `bash` commands will see). Defaults to the dedicated,
# low-privilege mcp-ssh service user; a broader account is an explicit opt-in.
DEFAULT_RUN_USER="mcp-ssh"
RUN_USER="$(ask "Run the service as which user? [$DEFAULT_RUN_USER]: ")"
RUN_USER="${RUN_USER:-$DEFAULT_RUN_USER}"
# Auto-provision only the dedicated service user; never silently create an
# arbitrary account the operator may have typed by mistake.
if ! id "$RUN_USER" >/dev/null 2>&1; then
[ "$RUN_USER" = "mcp-ssh" ] || die "user '$RUN_USER' does not exist"
echo "==> Creating dedicated service user '$RUN_USER' …"
useradd --system --create-home --shell /bin/bash "$RUN_USER" \
|| die "could not create user '$RUN_USER'"
fi
RUN_GROUP="$(id -gn "$RUN_USER")"

# 5. Public hostname for the DNS-rebinding guard (optional; loopback default).
PUBLIC_HOST="$(ask 'Public hostname for TLS proxy (blank = localhost only): ')"

# 6. Write config (chmod 600, owned by the run-as user so the service reads it).
install -d -m 755 "$CONF_DIR"
umask 077
cat >"$CONF" <<EOF
user = "$(toml_escape "$USER_NAME")"
pass = "$(toml_escape "$PASS1")"
EOF
Comment thread
coderabbitai[bot] marked this conversation as resolved.
chown "$RUN_USER:$RUN_GROUP" "$CONF"
chmod 600 "$CONF"

if [ -n "$PUBLIC_HOST" ]; then
cat >"$ENV_FILE" <<EOF
MCP_SSH_ALLOWED_HOSTS=localhost,127.0.0.1,$PUBLIC_HOST
EOF
else
# Blank on a rerun must drop any prior allowlist, else a stale public host
# keeps passing Host validation despite the "localhost only" choice.
rm -f "$ENV_FILE"
fi
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 7. systemd drop-in: run as the chosen user instead of the packaged default.
install -d -m 755 "$OVERRIDE_DIR"
cat >"$OVERRIDE_DIR/override.conf" <<EOF
[Service]
User=$RUN_USER
Group=$RUN_GROUP
EOF

# 8. Start it.
echo "==> Enabling + starting the service …"
systemctl daemon-reload
systemctl enable --now mcp-ssh
sleep 1

# 9. Verify on loopback.
echo "==> Verifying …"
if curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server >/dev/null; then
echo "✅ mcp-ssh is up on 127.0.0.1:1337 (running as $RUN_USER)."
else
echo "⚠️ Service did not answer yet — check: journalctl -u mcp-ssh -e" >&2
fi

cat <<EOF

Next steps:
• Logs: journalctl -u mcp-ssh -f
• Status: systemctl status mcp-ssh
• Put TLS in front (Caddy/nginx) → https://your-host/mcp (see docs/deploy.md)
• In Claude, add the remote MCP server https://your-host/mcp and log in
with the username/password you just set.
EOF
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ This is the core design idea. `bash` must serve both *"echo hello"* and *"a 20-m

When a command starts (`JobStore::run`):

1. The command is spawned via `sh -c`, with stdout **and** stderr merged into a single per-job **log file** (`MCP_SSH_JOB_DIR/<id>.log`). Logging to a file — not memory — is what lets long output be paginated later without holding it all in RAM.
1. The command is spawned via a bare `sh -c` by default — fast, no rc files. Pass `interactive=true` to `bash` and it runs through an **interactive bash** (`bash -ic`) instead, sourcing the service user's `~/.bashrc` so aliases and version managers (`mise`, `nvm`, `rbenv`) resolve exactly as in a real shell. Either way stdout **and** stderr are merged into a single per-job **log file** (`MCP_SSH_JOB_DIR/<id>.log`): the child's own stdio goes to `/dev/null` and the command re-points stdout+stderr at the log after startup, so bash's "no job control" warnings (no controlling TTY under systemd) never reach the log. Logging to a file — not memory — is what lets long output be paginated later without holding it all in RAM.
2. A background task owns the child process, waits for it to exit, and records the final `JobState` (`Running` / `Exited{code}` / `Failed{error}`).
3. The caller waits for **either** completion **or** the inline window (`MCP_SSH_INLINE_TIMEOUT_SECS`, default 2s; overridable per call via `bash`'s `timeout`). Passing `bg=true` skips the wait entirely and backgrounds at once:
- **Finished in time** → `RunResult::Inline` — status + first page of the log, returned now.
Expand Down
43 changes: 43 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ The login flow needs more than `/mcp` on the public origin: both the browser OAu

## ⚡ Install (Debian/Ubuntu)

### One-liner (recommended)

Downloads the latest release for your arch, prompts for a username + password, asks which OS user to run as, writes the config + a systemd drop-in, and starts the service:

```bash
curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh | sudo bash
```

Re-running it just updates the binary and re-applies your answers. Source: [`deploy/install.sh`](../deploy/install.sh).

### By hand

```bash
# 1. install from the .deb (GitHub releases)
sudo dpkg -i mcp-ssh_*.deb
Expand All @@ -25,6 +37,37 @@ curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server

The `.deb` installs the binary, a systemd unit, and `/etc/mcp-ssh/config.toml`.

## 🩺 Verify / debug with curl

All checks hit the loopback bind (`127.0.0.1:1337`); swap in `https://your-host` once the proxy is up.

```bash
# 1. OAuth discovery returns JSON ⇒ server is up
curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server | jq .

# 2. /mcp is bearer-only — no creds ⇒ 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:1337/mcp # → 401

# 3. Mint a bearer from your username/password (runs the OAuth PKCE flow).
# `bin/mcp-token` ships in the source checkout, NOT the .deb — run it from a
# repo clone with MCP_SSH_USER/MCP_SSH_PASS in env or .env, or skip to a GUI
# client's browser OAuth (see Connect a client).
read -rp 'MCP_SSH_USER: ' MCP_SSH_USER
read -rsp 'MCP_SSH_PASS: ' MCP_SSH_PASS; echo
TOKEN="$(MCP_SSH_USER="$MCP_SSH_USER" MCP_SSH_PASS="$MCP_SSH_PASS" bin/mcp-token)"
unset MCP_SSH_PASS
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 4. initialize a session — look for the `mcp-session-id:` response header
curl -sS -D - -o /dev/null -X POST http://127.0.0.1:1337/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Host: localhost' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
```

Discovery 404s behind your proxy ⇒ it isn't forwarding `/.well-known/*`, `/authorize`, `/token`, `/register` (see [Connect a client](#-connect-a-client)). Logs: `journalctl -u mcp-ssh -e`.

## 🔧 systemd

The packaged unit runs `mcp-ssh serve`. Run it as a **dedicated low-privilege user** and lock it down:
Expand Down
5 changes: 3 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,16 @@ Output and stderr are merged into one stream, terminal-style.

## 🐚 Shell & jobs

### `bash(cmd, cwd?, timeout?, bg?)`
Run a shell command (`sh -c`). Returns output inline if it finishes within the inline window, else a job id to monitor with `job`.
### `bash(cmd, cwd?, timeout?, bg?, interactive?)`
Run a shell command. By default it's a fast bare `sh -c`. Pass `interactive=true` to run it in an **interactive bash** (`bash -ic`) that sources your `~/.bashrc`, so aliases and version managers (`mise`, `nvm`, `rbenv`) work just like a normal shell. Returns output inline if it finishes within the inline window, else a job id to monitor with `job`.

| Param | Required | Default | Meaning |
|---|---|---|---|
| `cmd` | yes | — | the shell command |
| `cwd` | no | process cwd | working directory |
| `timeout` | no | 2s | seconds to wait inline before backgrounding |
| `bg` | no | false | `true` backgrounds immediately, returning the job id without waiting |
| `interactive` | no | false | `true` sources `~/.bashrc` (aliases, mise/nvm/rbenv) via `bash -ic` |

```
bash("ls -la /var/www")
Expand Down
14 changes: 12 additions & 2 deletions src/app_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ fn test_app() -> Router {

fn test_app_with(public_url: Option<&str>) -> Router {
let dir = tempfile::tempdir().unwrap().keep();
let jobs = JobStore::new(dir, std::time::Duration::from_secs(2)).unwrap();
let jobs = JobStore::new(
dir,
std::time::Duration::from_secs(2),
crate::jobs::Shell::sh(),
)
.unwrap();
let state = oauth::AuthState {
creds: auth::Credentials {
user: "admin".into(),
Expand All @@ -40,7 +45,12 @@ fn test_app_with(public_url: Option<&str>) -> Router {
/// Build a test app with a pre-minted bearer token ready for session tests.
async fn test_app_with_token() -> (Router, String) {
let dir = tempfile::tempdir().unwrap().keep();
let jobs = JobStore::new(dir, std::time::Duration::from_secs(2)).unwrap();
let jobs = JobStore::new(
dir,
std::time::Duration::from_secs(2),
crate::jobs::Shell::sh(),
)
.unwrap();
let oauth_store = Arc::new(oauth::Store::default());
let token = "test-bearer-token".to_string();
oauth_store.insert_token(&token).await;
Expand Down
Loading
Loading