Skip to main content
Glama
README.md
# local-terminal-mcp

A small, security-first [MCP](https://modelcontextprotocol.io) server that lets
an AI assistant — **ChatGPT**, **Claude Desktop**, **Claude Code**, or any other
MCP client — run a set of **allowlisted** commands and read files from a local
directory on your machine.

The motivating use case: point ChatGPT (or Claude) at a local codebase so it can
run `git`, `rg`, `cat`, etc. and analyse the repo — without copying files around
by hand, and, for ChatGPT, using your flat-rate subscription instead of metered
API/agent tokens.

> ⚠️ **This runs commands on your computer.** It is built to be safe by default
> (read-only, allowlisted, no shell, path-contained, auth-required over the
> network), but you are responsible for how you configure and expose it. Read
> [Security model](#security-model) before exposing it to the internet.

---

## Table of contents

- [Why this exists](#why-this-exists)
- [How it works](#how-it-works)
- [Security model](#security-model)
- [Install](#install)
- [Quick start (local / stdio)](#quick-start-local--stdio)
- [Connect to Claude Desktop](#connect-to-claude-desktop)
- [Connect to Claude Code](#connect-to-claude-code)
- [Connect to ChatGPT (over the internet)](#connect-to-chatgpt-over-the-internet) · [exact step-by-step guide](docs/CHATGPT_SETUP.md)
- [Configuration reference](#configuration-reference)
- [Tools exposed](#tools-exposed)
- [Development](#development)
- [FAQ](#faq)

---

## Why this exists

Running a coding agent against a large repo through a metered API burns tokens
fast. If you already pay for a ChatGPT or Claude subscription, you can instead
give the assistant a *tool* to read files and run read-only commands on your
machine, and let it analyse the code conversationally. That is what this server
provides, with a security model strong enough that you can leave write access
turned off and expose only read access.

## What you can do with it

- **Work on your codebase from ChatGPT without spending Codex/API tokens.**
  Let ChatGPT run `git`, `rg`, `cat`, `find`, etc. to read, search and reason
  about a local repo — billed to your flat ChatGPT subscription instead of
  metered agent/API usage.
- **Run local generators from ChatGPT — images, sprites, assets — unmetered.**
  The server runs *any program you allowlist*. Allowlist your own generation
  CLI or script and ChatGPT can trigger it locally, as many times as you like,
  without using ChatGPT's built-in image quota. For example, to let ChatGPT
  drive a local sprite/image script:

  ```bash
  local-terminal-mcp --transport http --port 3003 \
    --root /path/to/assets-project \
    --auth path --mcp-path "/mcp/$SECRET" \
    --allow-commands "python,node,convert,aseprite" \
    --allow-write
  ```

  Then ask ChatGPT to call `run_command` with e.g.
  `python gen_sprite.py --seed 42 --out sprites/hero.png`, and `read_file` /
  `list_directory` to inspect the results. (`--allow-write` is only needed if
  the generator writes into the root; keep it off for read-only analysis.)

> You decide exactly which programs are reachable. The default allowlist is
> read-only; everything beyond it is opt-in.

## How it works

```
  MCP client (ChatGPT / Claude Desktop / Claude Code)
        │
        │   stdio  ── local, no network  ────────────┐
        │                                            │
        │   HTTP (Streamable) + bearer token         │
        ▼                                            ▼
  cloudflared / reverse proxy  ──────────►  local-terminal-mcp
        (only needed for ChatGPT)                    │
                                                     ▼
                                     policy engine  →  git / rg / cat / ...
                                     (allowlist, no shell, path containment)
```

- **Local clients (Claude Desktop, Claude Code)** talk to the server over
  **stdio** — the server is a child process, nothing is exposed to the network.
  This is the most secure mode and needs no tunnel.
- **ChatGPT** can only reach servers over public **HTTPS**, so for ChatGPT you
  run the server in **HTTP** mode behind a tunnel (e.g. `cloudflared`) and
  protect it with a bearer token (ideally plus Cloudflare Access).

## Security model

The server never trusts the model's judgement. A deterministic policy layer
(`src/local_terminal_mcp/policy.py`) gates every request:

1. **Allowlist, deny by default.** Only commands whose program is on the
   allowlist run. Anything else is rejected. The default allowlist is
   read-only (`git`, `rg`, `grep`, `ls`, `cat`, `head`, `tail`, `wc`, `find`,
   `tree`, `stat`, `file`, `pwd`, `echo`, `diff`).
2. **One command, no shell.** Commands are parsed with `shlex` and executed
   with `shell=False`. Pipes (`|`), chaining (`&&`, `;`), redirects (`>`),
   background (`&`) and command substitution (`$(...)`, backticks) are all
   **rejected** — so `git status && rm -rf /` never runs.
3. **Path containment.** Every file path is fully resolved (symlinks and `..`
   included) and must land inside the configured root directory.
4. **Writes are opt-in.** `write_file` is disabled unless you start with
   `--allow-write`. The default is read-only.
5. **Fail closed on exposure.** The server refuses to start in HTTP mode without
   an auth token of at least 16 characters.
6. **Bounded output and time.** Output is truncated to a byte limit and every
   command has a timeout.

**Layers you add around it:**

- Put **Cloudflare Access** (or equivalent) in front of the tunnel. A bearer
  token is the app-level check; Access is the network-level wall. Obscure
  tunnel URLs are **not** security.
- Run the server as a **low-privilege user**, ideally inside a container or VM,
  not as your main account.
- Keep write access off unless you truly need it, and when you do, keep the root
  scoped to a single project directory.

## Install

Requires Python 3.10+.

```bash
git clone https://github.com/luckysolanki/local-terminal-mcp.git
cd local-terminal-mcp
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
```

## Quick start (local / stdio)

Run it read-only against a project directory:

```bash
local-terminal-mcp --root /path/to/your/repo
```

That starts the server on stdio, waiting for an MCP client. See the sections
below to connect a specific client.

## Connect to Claude Desktop

Add the server to Claude Desktop's config file:

- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
- Windows: `%APPDATA%\Claude\claude_desktop_config.json`

```jsonc
{
  "mcpServers": {
    "local-terminal": {
      "command": "/path/to/local-terminal-mcp/.venv/bin/local-terminal-mcp",
      "args": ["--root", "/path/to/your/repo"]
    }
  }
}
```

Restart Claude Desktop. The tools appear under the connectors / tools menu.

## Connect to Claude Code

```bash
claude mcp add local-terminal -- \
  /path/to/local-terminal-mcp/.venv/bin/local-terminal-mcp --root /path/to/your/repo
```

Then `/mcp` inside Claude Code will list the server.

## Connect to ChatGPT (over the internet)

> 📄 **For the exact, step-by-step tested procedure, see
> [docs/CHATGPT_SETUP.md](docs/CHATGPT_SETUP.md).** The summary below covers the
> same flow.

ChatGPT can only reach servers over public HTTPS, so run in HTTP mode behind a
tunnel. The flow is: **(1)** start the server, **(2)** expose it with a tunnel
(ngrok *or* cloudflared), **(3)** add the connector in ChatGPT.

> **Why `path` auth for ChatGPT?** ChatGPT's *Create custom MCP server* dialog
> only offers **OAuth** or **No authentication** — there is no field for a
> static bearer token. So instead of a header, we put an unguessable secret in
> the URL path (a *capability URL*) and select **No authentication** in ChatGPT.
> The MCP route exists only at that secret path; probes of the bare `/mcp`
> return 404. This is appropriate for personal use; note the secret appears in
> the URL (and thus in edge/proxy logs). For a shared or higher-value
> deployment, implement OAuth instead.

The server also validates the incoming `Host` header for DNS-rebinding
protection. Because tunnels forward an arbitrary public hostname, the server
accepts any host by default; pin it to your tunnel hostname with
`--allowed-hosts` for defense in depth.

### Step 1 — start the server in HTTP mode (path auth)

```bash
SECRET="$(openssl rand -hex 16)"
echo "MCP URL path: /mcp/$SECRET"
local-terminal-mcp --transport http --host 127.0.0.1 --port 8000 \
  --root /path/to/your/repo \
  --auth path --mcp-path "/mcp/$SECRET"
```

### Step 2 — expose it with a tunnel

<details open>
<summary><b>Option A — ngrok (works on the free tier)</b></summary>

Install ngrok and add your authtoken (one-time, from the ngrok dashboard):

```bash
brew install ngrok            # or: https://ngrok.com/download
ngrok config add-authtoken <YOUR_NGROK_AUTHTOKEN>
```

Start the tunnel pointing at the local port:

```bash
ngrok http 8000
```

ngrok prints a forwarding URL like `https://a1b2-34-56.ngrok-free.app`. Your
MCP endpoint is that URL + your secret path, e.g.
`https://a1b2-34-56.ngrok-free.app/mcp/<SECRET>`.

Notes for the **free tier**:

- The URL is **random and changes every restart** — you'll re-paste it into
  ChatGPT each session. (A paid plan gives a stable `--domain`.)
- ngrok shows a browser interstitial on the free tier for *browser* traffic;
  ChatGPT's MCP client sends API requests, so it is not affected.
- Optionally lock the Host header to the tunnel domain:

  ```bash
  local-terminal-mcp --transport http --port 8000 --root /path/to/repo \
    --auth path --mcp-path "/mcp/$SECRET" \
    --allowed-hosts a1b2-34-56.ngrok-free.app
  ```

</details>

<details>
<summary><b>Option B — cloudflared (stable custom domain)</b></summary>

With a named tunnel on your own domain (see
[`examples/cloudflared-config.yml`](examples/cloudflared-config.yml)):

```yaml
tunnel: lucky-tunnel
credentials-file: /Users/you/.cloudflared/<tunnel-id>.json
ingress:
  - hostname: mcp.yourdomain.com
    service: http://localhost:8000
  - service: http_status:404
```

```bash
cloudflared tunnel run lucky-tunnel
```

Your MCP endpoint is `https://mcp.yourdomain.com/mcp/<SECRET>`. (If you also
front it with Cloudflare Access, note ChatGPT cannot complete an Access login,
so use a reserved port/hostname routed directly to the server, as here, rather
than an Access-gated one.)

</details>

### Step 3 — add the connector in ChatGPT

Requires a plan that has the custom MCP feature (Plus/Pro/Team/Enterprise/Edu):

- **Plugins** → **Add ▾** → **Create custom MCP server**.
- **Name**: e.g. `Local Terminal`.
- **Server URL**: your tunnel URL + secret path
  (e.g. `https://a1b2-34-56.ngrok-free.app/mcp/<SECRET>`).
- **Authentication**: **No authentication** (the secret path is the credential).
- Tick **I understand and want to continue**, then **Create as a plugin** and
  **Connect**.

ChatGPT discovers the tools automatically. A `GET /healthz` endpoint (no auth)
is available for tunnel/uptime checks. To use the tools in a chat, ask ChatGPT
to use the connector by name.

## Configuration reference

Every option has a CLI flag and an `LTMCP_`-prefixed environment variable. CLI
flags win over environment variables.

| CLI flag | Env var | Default | Meaning |
|---|---|---|---|
| `--root` | `LTMCP_ROOT` | cwd | Directory the server is confined to |
| `--transport` | `LTMCP_TRANSPORT` | `stdio` | `stdio` or `http` |
| `--host` | `LTMCP_HOST` | `127.0.0.1` | HTTP bind host |
| `--port` | `LTMCP_PORT` | `8000` | HTTP bind port |
| `--auth` | `LTMCP_AUTH_MODE` | `bearer` | HTTP auth mode: `bearer` (header) or `path` (secret in URL) |
| `--auth-token` | `LTMCP_AUTH_TOKEN` | — | Bearer token (required for `bearer` mode) |
| `--mcp-path` | `LTMCP_MCP_PATH` | `/mcp` | Path the MCP endpoint is served at; for `path` auth, end it with a long random segment |
| `--allowed-hosts` | `LTMCP_ALLOWED_HOSTS` | any | Comma-separated `Host` header allowlist (e.g. your tunnel hostname) |
| `--allow-commands` | `LTMCP_ALLOW_COMMANDS` | read-only set | Comma-separated allowlist |
| `--allow-write` | `LTMCP_ALLOW_WRITE` | `false` | Enable `write_file` |
| `--max-output-bytes` | `LTMCP_MAX_OUTPUT_BYTES` | `100000` | Output truncation limit |
| `--timeout` | `LTMCP_TIMEOUT` | `120` | Per-command timeout (seconds) |

## Tools exposed

| Tool | Available when | Description |
|---|---|---|
| `run_command` | always | Run one allowlisted command (no shell). |
| `read_file` | always | Read a file inside the root. |
| `list_directory` | always | List a directory inside the root. |
| `write_file` | `--allow-write` | Write a file inside the root. |

## Development

```bash
pip install -e ".[dev]"
pytest            # run the test suite
ruff check .      # lint
```

The security-critical logic lives in `policy.py` and is covered by
`tests/test_policy.py`. If you change the policy, add a test for it.

## FAQ

**Can it run any command?** No — only programs on the allowlist, one at a time,
with no shell. Expand the allowlist with `--allow-commands` if you need more.

**How is the HTTP endpoint protected?** With a credential, never obscurity of
the tunnel hostname alone. Use `bearer` auth (header token) for clients that
support custom headers, or `path` auth (a long random secret in the URL, a
*capability URL*) for ChatGPT, whose UI only offers OAuth or no-auth. For a
shared or higher-value deployment, implement OAuth and/or front it with
Cloudflare Access.

**Why no pipes or `&&`?** Because allowing shell composition is the easiest way
to smuggle a dangerous command past an allowlist. Run multiple tool calls
instead.

## License

MIT — see [LICENSE](LICENSE).

TDQS

A3.6/5.0

Scored across 3 tools

Disambiguation4/5

The three tools have mostly clear roles, but run_command's allowlist includes cat, ls, find, and grep, which functionally overlaps with read_file and list_directory. An agent could read a file or list a directory via either path, though the dedicated tools are clearly documented as the simpler option.

Naming Consistency5/5

All three names follow a clean verb_noun snake_case pattern (run_command, read_file, list_directory) with no deviations or mixed conventions.

Tool Count4/5

Three tools is on the lean side, but the server is deliberately a narrow, sandboxed read-only surface, so each tool earns its place and nothing is redundant padding.

Completeness3/5

The read surface is coherent, but there is no write, create, edit, or delete capability anywhere in the set, and redirects/chaining are blocked in run_command, so agents hit a hard dead end for any mutation task. This may be intentional sandboxing but leaves the lifecycle incomplete.

Maintenance

ActivityMaintained
ResponsivenessNo issues