# remotify-mcp [Health: Active]

**Category:** 🖥️ Command Line  
**Repository:** https://github.com/onlsol/relay.remotify.run  
**GitHub Stars:** 3  
**npm Downloads (last month):** 4496  
**Views:** 1  
**Installs:** 0  
**Upvotes:** 0  
**Directory Page:** https://allmcps.com/mcp/remotify-mcp

## Description
Run approved shell commands on remote computers through AI assistants. The remote machine needs only curl and bash, with no SSH access or installed agent.

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

```json
"mcpServers": {
  "remotify-mcp": {
    "command": "npx",
    "args": ["-y","remotify-mcp@latest"]
  }
}
```

## Documentation & README

# HTTP command relay

Ephemeral HTTP command relay. Push a shell command from any HTTP client; have
it run on a remote machine that has nothing installed but `curl` and `bash`.

Useful whenever you need to bounce a command to a shell without opening an SSH
port, installing an agent, or deploying anything:

- LLM coding sessions driving an arbitrary remote host - any tool, self-hosted or cloud (MCP hosts, curl-literate chat models, local runners, etc.)
- CI runners poking at post-deploy state
- Admin shells behind NAT / firewalls
- Headless devices reachable only outbound

Data on the relay is minimised, not absent: each session gets a small
directory holding the pending command/result and a timestamped push history,
purged as soon as the idle TTL lapses (default 3h) or on explicit delete.
Session keys are 128-bit random.

## Architecture

```
  CLIENT                            RELAY                        REMOTE SHELL
  (LLM / CI / terminal)         (single vhost)                   (curl + bash)

   |                              |                              |
   |-- POST /api/session -------->|                              |
   |<-- key + URLs ---------------|                              |
   |                              |                              |
   |        (user pastes the remote-quickstart on the remote)    |
   |                              |                              |
   |                              |<-- curl /r/KEY?mode=... -----|
   |                              |   runner script starts       |
   |                              |                              |
   |-- POST /cmd-KEY ------------>|      queued on disk          |
   |<-- 201 ----------------------|                              |
   |   (client may disconnect)    |                              |
   |                              |<-- GET /cmd-KEY (poll) ------|
   |                              |-- 200 + command body ------->|
   |                              |                >>> prints cmd|
   |                              |                (y/N if supervised)
   |                              |                bash runs     |
   |                              |                              |
   |                              |<-- POST /result-KEY ---------|
   |                              |      queued on disk          |
   |                              |--- 201 --------------------->|
   |                              |                <<< done      |
   |-- GET /result-KEY (poll) --->|                              |
   |<-- 200 + output -------------|                              |
```

## Stack

| Component | Role | First-party code |
|-----------|------|------------------|
| nginx | Reverse proxy, TLS, rate limiting, static landing page | Config only |
| PHP-FPM | Session API, runner script generator, file-backed queue | Yes |
| certbot | Automatic TLS issuance + renewal (optional, `--profile tls`) | No |

No database. No worker. The only persistent state is a flat-file queue under
`data/sessions/<KEY>/` (bind-mounted from the host into the container at
`/var/data/sessions/<KEY>/`), data-minimised and purged on idle TTL or
explicit delete.

## Quick start

```bash
cp .env.example .env
docker compose up -d
```

The shipped `.env.example` defaults to a self-consistent local HTTP setup -
no editing required for a first run. After the command above, the API and
the one-liners it hands out are both reachable at `http://localhost:49180`.

For a public deployment, edit `.env` first: set `DOMAIN` to your real
hostname, `SCHEME=https`, `NGINX_MODE=tls`, `HTTP_PORT=80` (required so
Let's Encrypt's HTTP-01 challenge is reachable) and `HTTPS_PORT=443`, then:

```bash
docker compose --profile tls up -d         # HTTP + HTTPS with automatic TLS
```

That's the whole install. Everything else runs in containers.

## Environment

All knobs live in `.env`; nothing is hardcoded.

| Var | Default | Meaning |
|-----|-----------------|---------|
| `DOMAIN` | `localhost` | Public hostname for the API / landing page |
| `SCHEME` | `http` | `http` or `https` - baked into returned one-liners |
| `PUBLIC_PORT` | `49180` | Port baked into generated URLs. Leave empty for the standard port of `SCHEME` (80/443) |
| `NGINX_MODE` | `http` | `http` or `tls`. `tls` requires `--profile tls` |
| `HTTP_PORT` / `HTTPS_PORT` | `49180` / `49443` | Host ports published by nginx. Public HTTPS deploy: `80` / `443` (80 is required for the ACME HTTP-01 challenge) |
| `RATE_LIMIT` | `10r/m` | Per-IP rate on `POST /api/session` (nginx `limit_req` syntax) |
| `RATE_LIMIT_BURST` | `5` | Burst slots before 503 |
| `PROXY_TIMEOUT` | `3600` | Max seconds either side of a pipe waits for the other end. Must comfortably exceed `LONGPOLL_MS` |
| `MAX_BODY_SIZE` | `25m` | Single source of truth for body-size limits: nginx `client_max_body_size` on the queue endpoints, and the API derives its raw-body cap (`post_max_size`), memory limit, its gzip zip-bomb decoded-size cap (3x this value), and the runner's output-truncation cap (80% of this value) from it, so none of them can drift apart. `0` = unlimited at the nginx layer (PHP still keeps a finite decoded cap for safety) |
| `LONGPOLL_MS` | `15000` | Long-poll window (ms) `GET /cmd-{key}` / `GET /result-{key}` hold an empty slot open before returning `204`, so listeners pick up work sub-second instead of on the next poll tick |
| `FPM_MAX_CHILDREN` | `64` | PHP-FPM pool size. Each active session pins ~2 workers for up to `LONGPOLL_MS` at a time, so this bounds how many sessions can be live concurrently before other requests start queueing |
| `FPM_MAX_REQUESTS` | `500` | Requests a PHP-FPM worker serves before it is recycled, so no slow leak accumulates over a long uptime. `0` = never recycle |
| `SESSION_TTL` | `10800` | Idle TTL for a session (seconds). Every request on the key resets it; expired sessions are purged with their queue + history. |
| `AUDIT_LOG` | `0` | `1` = log key generation to container stderr. Independently of this, nginx's access log masks the key segment of every request line as `<key>` |
| `SOURCE_URL` | _(empty)_ | Target of the landing page's source link and of `GET /source`. Empty hides the link |
| `CERTBOT_EMAIL` | _(required for tls profile)_ | Contact address used when requesting certs from the ACME CA |
| `CERTBOT_STAGING` | `0` | `1` = use the ACME staging environment (test-only certs, avoids rate limits) |

The defaults above are a ready-to-run local HTTP setup - `cp .env.example .env
&& docker compose up -d` gives a working roundtrip at `http://localhost:49180`
with no edits. A public HTTPS deploy needs `DOMAIN` set to your real hostname,
`SCHEME=https`, `NGINX_MODE=tls`, `HTTP_PORT=80` (for the ACME HTTP-01
challenge) and `HTTPS_PORT=443`.

Behind your own reverse proxy? Skip the `tls` profile. Point your proxy at
`HTTP_PORT` on localhost and terminate TLS there. `SCHEME` and `PUBLIC_PORT`
describe what clients see, so behind a proxy on the standard port they are
`https` and `443` (or empty), not the internal `HTTP_PORT`.

The CI deploy renders `.env` with `scripts/render-env.sh`: `.env.example`
defaults overridden by same-named CI variables. For a non-localhost `DOMAIN`
it refuses `SCHEME=http` unless `ALLOW_PLAIN_HTTP=1`, and when `SCHEME` is
given without `PUBLIC_PORT` it uses the standard port of `SCHEME`, so a
public deploy cannot silently hand out `http://DOMAIN:49180/` one-liners.

## API

### `POST /api/session`
Generate a new session. Returns:

- `key` - 32-char hex, 128-bit entropy
- `ttl_seconds` - idle TTL (default 3h sliding; any request on the key resets it)
- `urls.cmd` / `urls.result` - queue push/pop endpoints for this session
- `urls.runner` - base URL of the runner script; append `?mode=auto` for unattended mode
- `urls.api` - `/api/session/<key>` for re-fetching the payload
- `remote_quickstart` - ready-to-paste one-liner, supervised mode (y/N per command)
- `remote_quickstart_auto` - the same line with `?mode=auto`, for unattended hosts; show operators both and let them pick
- `exec` - one-liner template with a `COMMAND` placeholder for pushing from any HTTP client
- `mcp_messages` - agent-facing message templates for the MCP client
  (`{version, templates}`, from `php/app/mcp-messages.json`). Serving the
  wording from the relay means prompt iterations deploy with the relay instead
  of requiring an npm re-publish of `remotify-mcp`; clients keep baked-in
  fallbacks, so the field is optional. Tool names/schemas/descriptions stay in
  the npm package deliberately (host approval surface). Omitted if the file is
  missing or invalid.

### `GET /api/session/{key}`
Re-fetch the same payload for a known key. Touches the session (resets its idle
timer) without consuming anything queued.

### `GET /api/session/{key}/status`
Cheap probe: returns `{cmd_queued, result_queued, cmd_in_flight,
cmd_in_flight_seconds_ago, listener_seen_seconds_ago}` without consuming
anything:

- `cmd_queued` / `result_queued` - whether a command/result is currently sitting in the hot slot.
- `cmd_in_flight` - a command was picked up by the listener and no result has been pushed back yet.
- `cmd_in_flight_seconds_ago` - seconds since that pickup (`null` when nothing is in flight).
- `listener_seen_seconds_ago` - seconds since the listener last polled `GET /cmd-{key}` (`null` if it never has).

Used by the MCP server to detect whether the listener has already picked up the
queued command.

### `POST /api/session/{key}/reset`
Recovery endpoint: drops any queued command, any queued result, and clears the
in-flight marker, while keeping the session (key, TTL, connected listener)
alive. Returns what it actually cleared:
`{"reset": true, "cleared": {"cmd_queued": ..., "result_queued": ..., "cmd_was_in_flight": ...}}`.
Rarely needed by hand - the relay self-heals a wedged in-flight marker as soon
as the listener reconnects, and the MCP server resets stale state on its own -
but useful for scripts and as an operator escape hatch.

### `DELETE /api/session/{key}`
Purges the session directory (queued cmd/result + any archived pushes) and
returns 204.

### `POST /cmd-{key}` / `GET /cmd-{key}`
POST pushes a command (plain bytes or `Content-Encoding: gzip`). GET consumes
the hot command slot - used by the listener. Last-write-wins: pushing while a
command is already queued replaces it; the old bytes survive as a timestamped
archive.

GET **long-polls**: if the slot is empty the PHP worker holds the connection
open (up to `LONGPOLL_MS`, default 15s) and returns the instant a command is
pushed - sub-second pickup in practice - or `204` once the deadline passes.
Pass `?nowait=1` to disable the hold and get an immediate `204`/body, useful
for synchronous probes.

A GET also **self-heals a wedged session**: polling for new work while a
command is still marked in-flight proves that command's executor is gone (an
executing listener never polls), so the relay clears the marker and queues a
synthetic `[remotify: ...]` result for whoever is still waiting on it. A
listener that died mid-command (kill -9, closed terminal, reboot) therefore
stops blocking the session the moment it - or a replacement - reconnects.

### `DELETE /cmd-{key}`
Drops the hot command slot without consuming (no archive, no listener side
effects). Used by the MCP server to cancel its own queued command when it
gives up waiting for a listener, so the command won't execute as an orphan
when one eventually connects.

### `POST /result-{key}` / `GET /result-{key}`
Symmetric endpoints for the listener to post results and the client to fetch
them. `GET /result-{key}` long-polls exactly like `GET /cmd-{key}` (same
`LONGPOLL_MS` window, same `?nowait=1` escape hatch).

### `GET /r/{key}?mode=supervised|auto`
Returns a ready-to-`bash` runner script. The remote operator runs:

```bash
curl -fsSL 'https://relay.remotify.run/r/KEY' | bash              # supervised
curl -fsSL 'https://relay.remotify.run/r/KEY?mode=auto' | bash    # auto
```

`supervised` previews every incoming command and waits for `y/N` (accepts `y`,
`Y`, `yes`, etc. - any response starting with y/Y). `auto` trusts every command
(only on hosts where the session key is fully private).

Both modes print each incoming command as `>>> <cmd>` before running it and
`<<< done (N bytes pushed back)` after the result is posted back, so the
operator can see traffic even in auto mode.

The runner exports a non-interactive env block (`DEBIAN_FRONTEND=noninteractive`,
`CI=true`, `TERM=dumb`, `PAGER=cat`, `SYSTEMD_PAGER=cat`, `GIT_TERMINAL_PROMPT=0`)
before executing each command, so apt, pagers, and git don't block on TTY input.
Sudo still prompts unless the caller uses `sudo -n` - the operator sees the
preview line and can choose to authenticate.

`supervised` mode needs an attached terminal to ask `y/N`; if none is present
(nohup, CI, piped in from something other than an interactive shell) the
runner exits immediately with a message pointing at `?mode=auto` instead of
hanging. The output of any command that exits non-zero gets a trailing
`[remotify: exit status N]` marker so the client can tell success from
failure; very large output is truncated to a head plus a marker rather than
dropped or rejected outright. Result pushes are gzip-compressed when `gzip`
is available on the remote, falling back to a plain POST when it isn't, so a
box with only `curl` + `bash` still works.

A poll answered `410` (session gone) or `426` (hostname retired) ends the
runner with a message, because neither can be fixed by retrying. Every other
poll failure is retried with a widening wait, 2s doubling up to 30s and reset
by the next answered poll, so a relay that is down is not polled - and its
error line not reprinted - every two seconds for as long as the listener is
left running.

### `GET /api/health`
Returns `{"ok": true, "service": "<DOMAIN>"}`, so a monitor can also confirm which relay answered.

### Requests arriving on a retired hostname

A name that still resolves here but is not `DOMAIN` is answered `426 Upgrade
Required` on every route, carrying the current endpoint and the fix. Nothing is
served on the old name; clients fail loudly until they are updated.

`POST /api/session` and `GET /api/session/{key}` are the exception: they answer
`200` with a session payload whose quickstart lines and `mcp_messages`
templates carry the notice in place of a command. Clients render those and
discard error bodies, so it is the only way the message reaches an operator.
The payload is dead by construction - no session exists behind it, and its URLs
use a scheme no HTTP client can open - so nothing runs.

Skipped entirely when `DOMAIN=localhost`, so a local stack reached over
`127.0.0.1` keeps working.

This includes health probes: an uptime monitor that hits the relay by IP or
by a hostname other than `DOMAIN` sees `426`, not `200`. Point monitors at
`GET https://DOMAIN/api/health` (or send a `Host: DOMAIN` header).

## Landing page

`GET /` serves a tiny zero-dependency HTML page that calls `/api/session`,
shows the generated key, the remote-side one-liner with a copy button, and the
`curl`-only exec template you can paste straight into any terminal. Useful
when an operator just needs the one-liner without hitting the API manually.

## Using it from an LLM tool

Two integration paths. Pick whichever your tool supports:

- **MCP** (most coding agents): your tool runs `mcp/server.js` locally and the model gets a native `remote_exec` tool.
- **HTTP / curl** (any tool with a Bash/shell tool): drop `templates/AGENTS.md.example` into your repo as the rules file your tool reads; the model calls the API itself with curl.

The MCP server is on npm as [`remotify-mcp`](https://www.npmjs.com/package/remotify-mcp) - every host below can use `npx` to run it with zero install.

### Claude Code

```bash
claude mcp add -s user remotify -- npx -y remotify-mcp@latest
```
`-s user` registers the server globally (in `~/.claude.json` at the user scope) so it's available from every working directory. Without `-s user` it lands in project-local scope and only appears in sessions whose cwd matches where you ran the command.

Verify:
```bash
claude mcp list          # remotify should be listed
```
Inside a session, the `/mcp` slash-command shows the live status and the exposed `remote_exec` tool.

### Cursor

Edit `~/.cursor/mcp.json`:
```json
{ "mcpServers": { "remotify": { "command": "npx", "args": ["-y", "remotify-mcp@latest"] } } }
```
Restart Cursor.

### Codex CLI (OpenAI)

Edit `~/.codex/config.toml`:
```toml
[mcp_servers.remotify]
command = "npx"
args    = ["-y", "remotify-mcp@latest"]
```

### Gemini CLI

Edit `~/.gemini/settings.json`:
```json
{ "mcpServers": { "remotify": { "command": "npx", "args": ["-y", "remotify-mcp@latest"] } } }
```

### Any other MCP-capable tool

Windsurf, Continue, Cline/Roo Code, Zed, VS Code's built-in MCP - they all accept the same `command` + `args` shape, just in a config file they each document. Drop the snippet in and you're done.

### Tools that are NOT MCP-capable (ChatGPT web, Ollama UIs, any bash-only agent, ...)

Drop [`templates/AGENTS.md.example`](https://github.com/onlsol/relay.remotify.run/blob/HEAD/templates/AGENTS.md.example) into your project as the rules file the tool reads - common filenames: `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, `CONVENTIONS.md`, `.github/copilot-instructions.md`. It teaches the model to `POST /api/session` and use curl to push/pull. No server-side integration needed.

### Overriding the relay

All snippets above default to `https://relay.remotify.run`. If you self-host, add one env var:
```json
"env": { "REMOTIFY_URL": "https://remotify.example.com" }
```


## Using it from anything else

If it speaks HTTP, it can talk to the relay. The only API call needed to
get started is `POST /api/session`; everything after that is an HTTP GET or
POST against `DOMAIN` - there is no separate `pipe.` subdomain, it's all one vhost.

## Security model

- **Key = access.** 128-bit random hex, unguessable at internet scale. Keep
  it private; anyone with the key can push commands to any listener on that key.
- **TLS is mandatory in production.** Keys travel in URL paths; HTTPS prevents
  interception.
- **nginx rate-limits** `POST /api/session` per IP (see `RATE_LIMIT`).
- **Data-minimised persistence.** Each session's pending command/result and
  push history live in a `0700` directory on the relay host, readable only by
  the PHP worker user. Kept only as long as needed: purged on the idle TTL
  (default 3h) or an explicit `DELETE`.
- **Supervised mode on the remote.** `/r/KEY?mode=supervised` prompts `y/N`
  before running each incoming command.
- **`--data-raw`** (rather than plain `-d` / `--data`) is used throughout to
  avoid curl's `@file` magic - a command that starts with `@` will not be
  read as a filename.

Threat-wise this is roughly equivalent to handing someone an SSH session:
commands run as whatever user pasted the remote one-liner. Do not paste the
one-liner on a shell you would not SSH into.

## License

Apache-2.0. See [`LICENSE`](https://github.com/onlsol/relay.remotify.run/blob/HEAD/LICENSE) and [`NOTICE`](https://github.com/onlsol/relay.remotify.run/blob/HEAD/NOTICE).

