termux-mcp
by YSCodex
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)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues