Skip to main content
Glama
README.md
# termux-mcp

An [MCP](https://modelcontextprotocol.io) server that turns an Android phone running
[Termux](https://termux.dev) into a remote machine you can drive with an AI agent.

Connect a client on your laptop, ask it to run a command, read a file, check the battery,
take a screenshot or send yourself a notification — and it happens on the phone.

- **45 tools**: shell, background jobs, tmux sessions, file management, the whole
  `termux-api` surface, package management and device state.
- **Two transports** from one codebase: `stdio` for a client on the same device, Streamable
  HTTP with bearer tokens for a client anywhere on your network.
- **Safe by construction, not safe by luck**: a confirmation gate on destructive commands, a
  jailed file API, token scopes, and an audit log of every call.

> [!WARNING]
> **This server grants shell access to your phone.** Anyone holding a `full` token can run
> anything Termux can run, read your files and use the Android APIs granted to Termux. Treat the
> token like a password, keep the HTTP bind address off the public internet, and prefer the
> read-only token for anything that only needs to look. Read [SECURITY.md](SECURITY.md) before
> you expose it to a network.

---

## Install

One command. It installs the project, writes a config, starts the server and tells you what to
type on your PC:

```sh
curl -fsSL https://raw.githubusercontent.com/YSCodex/termux-mcp/main/install.sh | bash
```

Add flags after `bash -s --`:

```sh
curl -fsSL https://raw.githubusercontent.com/YSCodex/termux-mcp/main/install.sh \
  | bash -s -- --with-tmux --with-api --with-opencode
```

The installer checks for Termux, `git`, `node` and `curl` — and installs them with `pkg` if they
are missing — then clones to `~/termux-mcp` and runs `scripts/setup.sh`. It is safe to run twice:
it updates an existing checkout, leaves a dirty one alone, and never rotates your tokens unless
you pass `--force`. `--dir`, `--ref` and `--source` change the location, version or origin.

To do it by hand instead:

```sh
git clone https://github.com/YSCodex/termux-mcp.git
cd termux-mcp
bash scripts/setup.sh          # or: npm install && node bin/termux-mcp.mjs --init
```

If you put the checkout on shared storage (`/storage/emulated/0`, `/sdcard` — which
`termux-setup-storage` exposes) you must add `--no-bin-links`, because Android's FUSE filesystem
cannot create the symlinks npm wants in `node_modules/.bin`. The installer and the setup script
both detect this and do it for you.

### Requirements

| | |
|---|---|
| Device | Android with [Termux](https://ftermux.com) installed |
| Node | 20 or newer (`pkg install nodejs`) — installed by the installer if absent |
| For termux-api tools | `pkg install termux-api` **and** the [Termux:API](https://github.com/termux/termux-api) app |
| For tmux tools | `pkg install tmux` |

The server itself is pure JavaScript. There is nothing to compile.

### Two Android quirks worth knowing

1. **`/storage` is mounted `noexec`.** A file there cannot be executed, so there is no
   `#!/usr/bin/env node` shortcut on shared storage. Always launch with an explicit node path:
   ```sh
   node ~/../storage/emulated/0/termux-mcp/bin/termux-mcp.mjs --stdio
   ```
   From inside Termux the node binary is `$PREFIX/bin/node`, usually
   `/data/data/com.termux/files/usr/bin/node`.
2. **Termux needs a real terminal.** Running inside a proot distro works, but commands
   that expect a TTY — and everything that touches the Android window system — behave better
   in a genuine Termux session.

## Quick start

The one-liner. It installs dependencies, writes a config with two tokens, starts the server and
prints what to type on your PC:

```sh
bash scripts/setup.sh
```

Useful flags: `--bind 0.0.0.0` (the default, so a PC on the hotspot can reach the phone),
`--port 8737`, `--with-tmux`, `--with-api`, `--with-opencode`, `--no-http`, `--force` to rotate
tokens. It is idempotent, and it will not touch an existing config unless you pass `--force`.

Doing it by hand instead:

```sh
# 1. Create a config with two freshly generated tokens
node bin/termux-mcp.mjs --init --bind 0.0.0.0 --port 8737

# 2a. Serve on stdio for a client on this device
node bin/termux-mcp.mjs --stdio

# 2b. Or serve on HTTP for a client somewhere else
node bin/termux-mcp.mjs --http --bind 0.0.0.0 --port 8737
```

`--init` prints your tokens once. They are written to `~/.termux-mcp/config.json` with mode
`600`. Keep that file out of version control — it is the only thing between a client and your
phone.

### Reaching the phone from a PC over the hotspot

`0.0.0.0` makes the server listen on every interface, so a laptop joined to the phone's hotspot
can connect. Two things make that safe enough to be convenient:

- **Bearer tokens.** Every request needs one. The read token is enough for browsing.
- **A private-network Host allowlist.** `http.allowedHosts` defaults to loopback plus
  `192.168.0.0/16`, `10.0.0.0/8`, `172.16.0.0/12` and `100.64.0.0/10` — a Wi-Fi network, the
  phone's own hotspot, or a VPN. It is deliberately **not** `0.0.0.0/0`, so a public client
  cannot forge a Host header and get in, and the DNS-rebinding guard stays armed.

Turn the hotspot on, then on the PC use the address the server printed at startup:

```sh
curl http://192.168.43.1:8737/health
```

The Android hotspot gateway is normally `192.168.43.1`, but read the banner rather than assume
it. The listener is IPv4 only, so a globally routable IPv6 address on the phone is not exposed —
the server says so at startup if it finds one.

If you would rather not expose a port at all, use the SSH recipe below. It is strictly better;
the hotspot route is the convenient one.

## Client setup

### opencode

```jsonc
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "termux": {
      "type": "local",
      "command": [
        "/data/data/com.termux/files/usr/bin/node",
        "/storage/emulated/0/fun/termux-mcp/bin/termux-mcp.mjs",
        "--stdio"
      ],
      "enabled": true,
      "timeout": 20000
    }
  }
}
```

For the phone as a remote target, point opencode at the HTTP endpoint instead:

```jsonc
{
  "mcp": {
    "termux": {
      "type": "remote",
      "url": "http://100.64.0.1:8737/mcp",
      "enabled": true,
      "oauth": false,
      "headers": { "Authorization": "Bearer {env:TERMUX_MCP_TOKEN}" },
      "timeout": 120000
    }
  }
}
```

### Claude Code

Over HTTP:

```sh
claude mcp add --transport http termux http://100.64.0.1:8737/mcp \
  --header "Authorization: Bearer $TERMUX_MCP_TOKEN"
```

Over SSH, which avoids opening a port at all:

```sh
claude mcp add termux -- ssh -T phone \
  /data/data/com.termux/files/usr/bin/node \
  /storage/emulated/0/fun/termux-mcp/bin/termux-mcp.mjs --stdio
```

### Claude Desktop

Claude Desktop speaks stdio only, so tunnel the phone over SSH and point it at the tunnel:

`claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "termux": {
      "command": "ssh",
      "args": [
        "-T", "-N", "-L", "8737:127.0.0.1:8737", "phone",
        "/data/data/com.termux/files/usr/bin/node",
        "/storage/emulated/0/fun/termux-mcp/bin/termux-mcp.mjs", "--stdio"
      ]
    }
  }
}
```

### VS Code, Cursor

```json
{
  "servers": {
    "termux": {
      "type": "stdio",
      "command": "/data/data/com.termux/files/usr/bin/node",
      "args": ["/storage/emulated/0/fun/termux-mcp/bin/termux-mcp.mjs", "--stdio"]
    }
  }
}
```

Cursor uses the same shape under `mcpServers` in `.cursor/mcp.json`.

## Tools

### Shell

| Tool | What it does |
|---|---|
| `shell` | Run a command, return stdout, stderr and the exit code. `cwd`, `env`, `timeout_ms`, `max_output_bytes`. |
| `shell_bg` | Start a detached command, get a job id and a log file. |
| `shell_jobs` | Every job, with pid, log path and whether it is still alive. |
| `shell_logs` | Tail of a job's log. |
| `shell_kill` | Signal a job's whole process group. |
| `tmux_open` / `tmux_send` / `tmux_capture` / `tmux_list` / `tmux_kill` | Persistent interactive sessions. |
| `tools_status` | Which optional pieces work right now (tmux, which `termux-*` binaries exist). |

### Files

`fs_read`, `fs_write`, `fs_list`, `fs_stat`, `fs_mkdir`, `fs_move`, `fs_delete`, `fs_search`,
`fs_tar`, `fs_download`.

`fs_read` takes `offset` and `limit` for line ranges, or `encoding: "base64"` for binaries.
`fs_tar` is the fast path for bulk transfer in either direction. `fs_download` fetches a URL
straight to disk.

### Termux and Android

`api_run` for any installed `termux-*` helper, plus first-class tools: `battery_status`,
`clipboard_get`, `clipboard_set`, `toast`, `vibrate`, `wifi_info`, `wifi_scan`, `location`,
`screenshot`, `camera_photo`, `mic_record`, `notification`, `dialog`, `open_url`, `share`,
`sensor_list`, `sensor_read`, `telephony_info`, `call_log`, `contact_list`, `sms_list`,
`sms_send`, `tts_speak`, `media_list`.

Media tools write into `~/.termux-mcp/media/` and return the path; pass `include_image: true` to
get the picture inline as an image block.

### Device state and maintenance

`system_info`, `network_info`, `storage_info`, `proc_list`, `proc_kill`, `pkg_list`,
`pkg_install`, `pkg_upgrade`, `audit_tail`, `termux_info`.

## Safety model

**Destructive commands need `confirm: true`.** A command that matches the destructive set —
recursive deletes, `mkfs`, `dd` to a device, writes to a block device, fork bombs, package
removal, reboots, `su`, SELinux tampering, writes into `/system` — is refused until the caller
passes `confirm: true`. The same applies to `fs_delete`, `fs_move` over an existing file,
`fs_tar` extract, `pkg_upgrade`, `proc_kill`, `sms_send` and `tmux_kill`.

**The file API is jailed.** `fs_*` tools only touch paths under `paths.allow`, minus anything
under `paths.deny`. Deny entries can carve out exceptions, which is how `$PREFIX` stays
reachable while the rest of `/data/data` does not. The audit log and the config file are
protected: no tool can rewrite them.

**The shell is not jailed.** That is the point of the tool, and it is why the destructive gate
exists. If you need a hard boundary, give the client a `read` token and it will not reach the
shell at all.

**Tokens carry scopes.** `read` reaches the read-only tools; `full` reaches everything. A tool
is scoped by its `readOnlyHint` annotation, so the two lists cannot drift apart.

**Every call is audited.** Tool name, caller, arguments (truncated), duration and outcome go to
`~/.termux-mcp/audit.log` as JSON lines, rotating at 5 MiB. Read it back with `audit_tail`.

**HTTP is locked down.** Bearer token compared in constant time, Host and Origin header
validation against `http.allowedHosts` to blunt DNS rebinding, `/health` with no sensitive data,
and a `403` for any request whose Host is not allowed.

## Configuration

`~/.termux-mcp/config.json`, written by `--init`. See [config.example.json](config.example.json)
for a fully commented starting point. Point `TERMUX_MCP_CONFIG` elsewhere to keep several
profiles.

| Key | Default | Notes |
|---|---|---|
| `http.enabled` | `true` | Set `false` to keep the file around but serve nothing. |
| `http.bind` | `127.0.0.1` | `0.0.0.0` listens everywhere, which is what a PC on the hotspot needs. |
| `http.port` | `8737` | |
| `http.path` | `/mcp` | |
| `http.allowedHosts` | loopback + `192.168.0.0/16`, `10.0.0.0/8`, `172.16.0.0/12`, `100.64.0.0/10` | Exact hosts, CIDR ranges, or `*` to accept anything. CIDR entries switch the guard from exact matching to range matching. |
| `tokens[]` | none | `{ name, token, scopes }` where scopes is `read` or `full`. |
| `paths.allow` | Termux home, `$PREFIX`, `/storage/emulated/0`, `/tmp` | File API roots. |
| `paths.deny` | system, vendor, proc, dev, etc, other apps' data | Supports `{ path, except: [] }`. |
| `limits.maxOutputBytes` | 256 KiB | Per command, stdout and stderr combined. |
| `limits.defaultTimeoutMs` | 30 s | Raised by `timeout_ms`, capped by `maxTimeoutMs`. |
| `limits.maxConcurrentJobs` | 8 | Foreground queue and background jobs. |
| `api.deny` | `termux-reboot`, `termux-chroot`, … | Binaries `api_run` will not launch. |

Environment overrides: `TERMUX_MCP_CONFIG`, `TERMUX_MCP_TOKEN`, `TERMUX_MCP_BIND`,
`TERMUX_MCP_PORT`, `TERMUX_MCP_PATH`, `TERMUX_MCP_HTTP=0`, `TERMUX_MCP_STATE_DIR`.

## Run it at boot

Install [Termux:Boot](https://github.com/termux/termux-boot), then:

```sh
mkdir -p ~/.termux/boot
cat > ~/.termux/boot/termux-mcp <<'EOF'
#!/data/data/com.termux/files/usr/bin/bash
termux-wake-lock
/data/data/com.termux/files/usr/bin/node \
  /storage/emulated/0/fun/termux-mcp/bin/termux-mcp.mjs --http \
  >> ~/.termux-mcp/boot.log 2>&1
EOF
chmod +x ~/.termux/boot/termux-mcp
```

`termux-wake-lock` keeps the CPU awake so long commands are not killed when the screen sleeps.

## Troubleshooting

**Every termux-api tool times out or returns nothing.** The binaries and the app are separate
installs. Run `pkg install termux-api`, install the Termux:API app, then `termux-api-start`.
Call `tools_status` to see what is actually present.

**A tool says a path is outside the allowed roots.** That is the jail. Add the path to
`paths.allow`, or use `shell` with a `full` token if you genuinely need it.

**A destructive command was refused.** That is the gate. If you meant it, pass `confirm: true`.

**`EACCES` when launching the server.** You are on `noexec` shared storage. Launch with
`node /path/to/bin/termux-mcp.mjs`, do not try to execute the file.

**npm fails with `EPERM` or `ENOENT` on symlinks.** You are on shared storage. Use
`npm install --no-bin-links`.

**The HTTP client cannot connect.** Check the bind address is reachable and that the address is
in `http.allowedHosts`; the Host check answers `403` to everything else. `curl /health` from a
browser-free shell to confirm the server is alive.

**A PC on the hotspot gets `403`.** The Host header carries the address the client dialled, so it
must fall inside `http.allowedHosts`. The default private ranges cover a normal hotspot; if you
are bridging into some other subnet, add it, or add the exact address. Note that `allowedHosts`
is the *client's* view of the address, not the server's.

**`"x.x.x.x" is not an address on this device`.** Your VPN address changed — Android hands out a
new one when the phone reconnects on a different cellular interface. The server lists its current
addresses before it exits. Either update `http.bind` and `http.allowedHosts`, or pass the new
address for one run with `--bind <addr> --port <port>`, or bind to `0.0.0.0` with the private-range
allowlist and stop caring. The stdio transport never has this problem.

**Remote tools over HTTP are slow or flaky.** Put the phone and the client on the same VPN
(Tailscale or WireGuard) and bind to that address instead of relying on a carrier network.

## Development

```sh
npm install --no-bin-links   # on shared storage
node scripts/check.mjs       # syntax check and credential scan
node test/smoke.mjs          # 36 tests: config, tools, policy, both transports
```

The smoke test runs entirely against a temporary directory, boots the server over stdio and over
HTTP, and asserts that a bad token gets a `401`, a bad Host gets a `403`, a private-network Host
is accepted while a public one is not, a read token cannot write, and a destructive command
without `confirm` is refused. It needs no Android device, which is why CI can run it on Linux.

Add a tool in `src/tools/`, give it a schema, set `annotations.readOnlyHint` correctly — the
scope gate reads that flag — and register it in the matching `register*Tools` function.

## Status

Beta. The tool surface and the safety gates are tested; the termux-api wrappers track the flags
shipped by the installed `termux-api` package and may need adjusting when Termux changes them.
`tmux` support is untested here because tmux was not installed on the test device.

## License

[MIT](LICENSE)