Skip to main content
Glama
README.md
# wp-remote-bridge

A local MCP server (Node/TypeScript, stdio transport) plus a small PHP bridge that gives an AI agent — or any MCP client — controlled, audited, reversible access to a WordPress site hosted on **shared hosting with no SSH, no WP-CLI, and no remote MySQL** (port 3306 closed). The only access the host gives you is FTP/SFTP (and sometimes phpMyAdmin).

Two capabilities:

1. **Guarded SQL** against the site's MySQL/MariaDB database (read by default, write only under explicit, time-boxed, per-environment policy).
2. **Script execution** (`wp_run`): run a PHP script, written and tested ahead of time, with WordPress fully loaded — the equivalent of `wp eval-file` on a server that doesn't have WP-CLI.

Both are reached over HTTPS through a tiny PHP file dropped at a random path in the web root; FTP/SFTP is only used for the bridge's own lifecycle (deploy/enable/disable/remove) and for uploading `wp_run` scripts.

## Architecture

```
┌──────────────────────────┐        stdio        ┌────────────────────────────────┐
│ MCP client (agent)       │ ───────────────────▶ │ wp-remote-bridge (Node/TS)     │
└──────────────────────────┘                      │  - typed MCP tools (zod)       │
                                                   │  - client-side SQL hint        │
                                                   │  - HMAC request signing        │
                                                   │  - local JSONL audit log       │
                                                   └───────┬────────────────┬───────┘
                                      HTTPS (signed POST)  │                │ SFTP/FTP(S)
                                                            ▼                ▼ (deploy/enable/disable/
                                          ┌─────────────────────────┐      remove bridge, upload
                                          │ <webRoot>/<random-path>/│      wp_run scripts)
                                          │   bridge.php            │
                                          │   run.php               │
                                          │   .htaccess             │
                                          └───────┬─────────────────┘
                                                  │ DB credentials read from the site itself
                                                  │ wp_run: require <webRoot>/wp-load.php
                                                  ▼ PDO, localhost
                                          ┌─────────────────────────┐
                                          │ MySQL / MariaDB         │
                                          └─────────────────────────┘

                                          ┌─────────────────────────┐
                                          │ <privateRoot>/wrb/      │  scripts/, logs/, nonces/,
                                          │                         │  enabled-until, secret
                                          └─────────────────────────┘  (outside the web root)
```

## Security model

- **Signed, obfuscated protocol.** A single `POST`, JSON body base64-encoded in field `p` (plain SQL or PHP script names in a POST body are often blocked by mod_security on shared hosting). Headers `X-WRB-Ts` (epoch seconds), `X-WRB-Nonce` (128-bit random), `X-WRB-Sig = HMAC-SHA256(secret, ts\nnonce\nsha256(body))`. The bridge rejects clock skew > 60 s, a replayed nonce, or a signature that fails `hash_equals`.
- **Random bridge path.** The bridge files live at a random, unguessable path under the web root, generated once and kept in local client state.
- **Kill switch.** The bridge only answers while `<privateRoot>/wrb/enabled-until` (outside the web root) holds a future timestamp; otherwise it returns a bare `404` with no body. `bridge_enable` sets it (2 h default, 8 h max); `bridge_disable` clears it. Expiry is automatic even if you forget to disable it.
- **Per-environment policy baked into the bridge.** Each environment declares `allowWrite` (SQL writes) and `allowRun` (`wp_run`), both `false` by default. The policy is written into the bridge file itself at deploy time and enforced there — not read from the request, so even a valid HMAC signature can't write to or run a script on a closed environment. Changing the policy requires redeploying.
- **Bridge-side SQL classification is the only authority.** The client does a cosmetic classification for agent ergonomics, but the PHP bridge classifies every statement itself (read / write / forbidden) and never trusts the client's judgment.
- **Read-only transactions, one statement per call.** Reads run inside `START TRANSACTION READ ONLY` + `ROLLBACK`; PDO has multi-statements and emulated prepares disabled; a bare `;` outside a string/comment is rejected.
- **Hard blacklist**, rejected in every mode: `DROP DATABASE`, `CREATE USER`, `GRANT`, `REVOKE`, `SET GLOBAL`, `INTO OUTFILE`, `INTO DUMPFILE`, `LOAD DATA`, `LOAD_FILE(`, `SHUTDOWN`, `KILL`, `INSTALL PLUGIN`, `CALL`, `PREPARE`, `HANDLER`, `LOCK TABLES`.
- **Bounds.** At most 1000 rows and 2 MB per response (`truncated: true` beyond that), a 15 s statement timeout.
- **Backup before write.** A real (non-dry-run) write requires a `backup_ref` from a backup taken on the touched table less than 30 minutes earlier; destructive tools default to `dry_run: true`.
- **No secrets anywhere in output.** Credentials, passwords, and HMAC secrets never appear in responses, logs, or error messages; PDO errors and script output are sanitized first.
- **Audit on both sides.** Every call is logged by the bridge (`<privateRoot>/wrb/logs/wrb.log`, outside the web root) and by the client (`~/.wp-remote-bridge/audit.jsonl`): timestamp, environment, action, a SHA-256 fingerprint of the SQL or script, truncated SQL, duration, rows/affected or status — never the SQL parameters or any secret.

## Requirements

- Node.js ≥ 20
- A target site running PHP ≥ 8.1 with the `pdo_mysql` extension
- MySQL or MariaDB reachable as `localhost` from PHP on that host (this is exactly the case the tool is built for: the database isn't reachable from outside)
- FTP, FTPS, or SFTP access to the site

## Install & build

```bash
git clone https://github.com/seangoogoo/wp-remote-bridge-mcp.git
cd wp-remote-bridge-mcp
npm install
npm run build        # tsc → dist/
npm run gen-secret    # prints a 64-hex-character HMAC secret, one per environment
```

## Configuration

Nothing site-specific is committed to this repository. You describe each site in your own, gitignored `envs.json` (see `envs.example.json` for the shape, with no secrets):

```jsonc
{
  "<env-name>": {
    "label": "human-readable label",
    "siteUrl": "https://example.com",
    "transport": {
      "type": "local" | "ftp" | "ftps" | "sftp",
      "host": "…",        // required unless type is "local"
      "port": 22,          // optional
      "webRoot": "/www",   // where the bridge files get dropped
      "privateRoot": "/"   // where wrb/ (outside the web root) is created; must not be inside webRoot
    },
    "dbCredentials": {
      "mode": "wp-config"  // reads literal define('DB_*', '…') from wp-config.php, no execution
      // or:
      // "mode": "include", "includePath": "../secrets.php",
      // "constants": { "name": "DB_NAME", "user": "DB_USER", "password": "DB_PASSWORD", "host": "DB_HOST" }
    },
    "serverSecret": {
      "mode": "file"       // bridge_deploy drops the HMAC secret at <privateRoot>/wrb/secret
      // or:
      // "mode": "env", "name": "SOME_CONSTANT" // exposed by the dbCredentials "include" file; requires that mode
    },
    "tablePrefix": "wp_",  // WordPress table prefix, used by the wp_option_* tools (default "wp_")
    "allowWrite": false,   // SQL writes; false by default
    "allowRun": false      // wp_run; false by default
  }
}
```

Credentials and secrets are never stored in `envs.json`. They're read from the process environment (e.g. via `.envrc`/direnv):

| Variable | Purpose |
|---|---|
| `WRB_ENVS` | Path to your `envs.json` |
| `WRB_HOME` | Local state + audit log directory (default `~/.wp-remote-bridge`) |
| `WRB_SECRET_<ENV>` | HMAC secret for environment `<ENV>` |
| `WRB_FTP_USER_<ENV>` / `WRB_FTP_PASSWORD_<ENV>` | FTP/SFTP credentials for `<ENV>` (not needed for a `"local"` transport) |

Naming rule (`envVarName` in `src/config.ts`): `<PREFIX>_<ENV-UPPERCASED-WITH-DASHES-AS-UNDERSCORES>`, e.g. environment `site-staging` → `WRB_SECRET_SITE_STAGING`, `WRB_FTP_USER_SITE_STAGING`.

See `.env.example` for a template to copy into `.envrc` or your shell environment.

## Registering with an MCP client

For Claude Code (or any client reading `.mcp.json`):

```json
{
  "mcpServers": {
    "wp-remote-bridge": {
      "command": "node",
      "args": ["/absolute/path/to/wp-remote-bridge-mcp/dist/index.js"],
      "env": {
        "WRB_ENVS": "/absolute/path/to/your/envs.json",
        "WRB_SECRET_STAGING": "…",
        "WRB_FTP_USER_STAGING": "…",
        "WRB_FTP_PASSWORD_STAGING": "…"
      }
    }
  }
}
```

The Inspector doesn't inherit your shell environment either; pass variables explicitly:

```bash
npx @modelcontextprotocol/inspector --cli -e WRB_ENVS=/tmp/envs.json -e WRB_HOME=/tmp/wrb-home \
  -e WRB_SECRET_LOCAL=<hex> node dist/index.js --method tools/list
```

## Typical workflow

1. `bridge_deploy` — drops `bridge.php`, `run.php`, `.htaccess` at a random path under `webRoot`, creates `wrb/` (`scripts/`, `logs/`, `nonces/`) outside the web root, and bakes the environment's `allowWrite`/`allowRun` policy into the bridge. Re-running it redeploys in place (needed after any policy change).
2. `bridge_enable` — opens the kill switch for up to 8 hours (2 h default).
3. Work: `db_list_tables` / `db_describe` / `db_query` / `db_explain` to read; `db_backup_table` then `db_execute` (with `confirm` and a fresh `backup_ref`) for a guarded write; or upload and run a script:
   - `wp_script_upload` uploads a PHP script to `wrb/scripts/` and returns its SHA-256.
   - `wp_run` executes it with WordPress loaded, verifying that SHA-256 first. Scripts that process many items must batch themselves: a positional `<label> [offset] [limit]` convention (shared hosts cut long-running HTTP requests), each call reporting how many items it processed and the next offset, and must be idempotent — rerunning a batch must not create duplicates. The bridge installs a minimal `WP_CLI` shim: `log()`/`warning()`/`success()` append to the captured output, `error()` appends an error line, sets `status: 'error'`, and halts the script (like `WP_CLI::halt`); `WP_CLI::runcommand()` is not supported and throws — no nested WP-CLI subcommands.
   - `wp_script_remove` deletes the script once you're done with it.
4. `bridge_disable` — close the kill switch when done (it also expires on its own).

After upgrading wp-remote-bridge, run `bridge_deploy` again on each environment: the bridge file embeds its configuration (policy, table prefix) and only supports the actions it was rendered with.

## Tools

| Tool | Main inputs | Effect | Guard |
|---|---|---|---|
| `bridge_status` | `env` | Local deploy state, then a signed `ping` if deployed: bridge version, environment, DB engine/version, PHP version, kill-switch expiry. A disabled or absent bridge is returned as data, not an error. | — |
| `bridge_deploy` | `env` | Deploys/redeploys `bridge.php`, `run.php`, `.htaccess`; creates `wrb/`; bakes in `allowWrite`/`allowRun`. | — |
| `bridge_enable` | `env`, `minutes` (≤ 480) | Opens the kill switch. | — |
| `bridge_disable` | `env` | Closes the kill switch. | — |
| `bridge_remove` | `env` | Deletes the bridge files and confirms a `404` afterwards. `wrb/` (logs, secret, scripts) is kept outside the web root. | — |
| `db_list_tables` | `env`, `like?` | Tables: engine, estimated rows, size. | read-only |
| `db_describe` | `env`, `table` | Columns, indexes, `SHOW CREATE TABLE`. | read-only |
| `db_query` | `env`, `sql`, `params?`, `limit?` | `SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN`, ≤ 1000 rows / 2 MB. | I1, I2, I5 |
| `db_explain` | `env`, `sql` | `EXPLAIN FORMAT=JSON`, without executing the statement. | read-only |
| `db_backup_table` | `env`, `table`, `where?` | Paginated SQL export to a timestamped local backup; returns a `backup_ref`. | read-only |
| `db_execute` | `env`, `sql`, `params?`, `confirm`, `backup_ref`, `dry_run=true` | `INSERT`/`UPDATE`/`DELETE`/`REPLACE` against a single table. With `dry_run`, runs inside a transaction and rolls back, still reporting `affected`; refused when the statement mentions a non-InnoDB table or a view. | `allowWrite` (I6), fresh `backup_ref` (I7), `confirm` |
| `wp_option_get` | `env`, `name` | Reads one row of `<tablePrefix>options`. PHP-serialized values are decoded (`decoded: true`) unless they contain an object, in which case the raw string is returned (`decoded: false`). Values over 2 MiB are not returned (`truncated: true`, `bytes`). Reads the database directly: the WordPress object cache may differ. | read-only |
| `wp_option_set` | `env`, `name`, `value`, `create=false`, `confirm`, `backup_ref`, `dry_run=true` | Writes one row of `<tablePrefix>options`. Arrays/objects and options already stored serialized are written with PHP `serialize()`; scalars follow WordPress conventions (`true` → `'1'`, `false`/`null` → `''`). Refuses options holding PHP objects; a missing option needs `create: true`. `dry_run` only reads and reports `would_affect` / `op` (no write is sent to the database). | `allowWrite` (I6), fresh `backup_ref` on `<tablePrefix>options` (I7), `confirm` |
| `db_search_replace` | `env`, `table`, `search`, `replace`, `columns?`, `after?`, `limit?` (≤ 500, default 200), `dry_run=true`, `confirm?`, `backup_ref?` | Serialization-aware search/replace in one table with a single-column primary key (PHP-serialized and double-serialized values are rewritten with correct lengths; cells holding PHP objects or invalid serialized data are skipped and counted). Processes one batch per call in primary-key order: call again with `after = next_after` until it is `null`. A real run locks the batch (`SELECT … FOR UPDATE`) and writes it in one transaction, and is refused on non-InnoDB tables. `dry_run` (counts only, read-only transaction) is allowed even when `allowWrite` is false. | real run: `allowWrite` (I6), `confirm`, fresh `backup_ref` on that table (I7) |
| `db_schema_refresh` | `env` | Refreshes the cached schema snapshot backing the `wrb://{env}/schema` resource. | read-only |
| `wp_script_upload` | `env`, `localPath` | Uploads a script to `wrb/scripts/`, returns its SHA-256. | — |
| `wp_run` | `env`, `script`, `args[]`, `user?`, `defines?`, `confirm` | Runs an uploaded script with WordPress loaded; returns captured output. | `allowRun` (I6), fingerprint check, `confirm` |
| `wp_script_remove` | `env`, `script` | Deletes a script from `wrb/scripts/`. | — |

MCP resource: `wrb://{env}/schema` — a cached table/column schema snapshot for `env`, useful as background context for an agent (kept fresh by `db_schema_refresh`).

## Testing

```bash
npm test          # vitest
npx tsc --noEmit
```

Pure TypeScript tests (config parsing, HMAC signing, tool wiring against an in-memory MCP transport) run everywhere. The PHP bridge integration tests additionally spin up `php -S` against a rendered bridge and need a local MySQL/MariaDB instance with a `wrb_test` database and a `wrb_test`/`wrb_test_pw` user (see `tests/helpers/bridge-harness.ts`); they detect that setup automatically and are skipped if it's not present.

## Limitations

- Not a general file manager: only the bridge's own files and `wp_run` scripts are written by FTP/SFTP. Managing the rest of the site's files is out of scope.
- No phpMyAdmin scraping — that approach is fragile and version-dependent.
- `db_execute` only targets a single table per statement (needed to determine which backup is required); multi-table writes must be split into several calls.
- `db_execute` with `dry_run` relies on a transaction rollback, so the bridge refuses a dry run whose SQL mentions a non-InnoDB table or a view (conservative whole-word match on table names). Writes made by triggers on other tables are not detected.
- `wp_run` has no nested WP-CLI subcommand support (`WP_CLI::runcommand()` throws) — it's a minimal `wp eval-file` equivalent, not a full WP-CLI shim.
- Destructive DDL (`DROP DATABASE`, `GRANT`, `CREATE USER`, …) is hard-blocked everywhere, with no override.
- The tool has no built-in notion of "production": closing an environment to writes is a configuration choice (`allowWrite`/`allowRun: false`) you make per site, not something the tool infers.

## License

[MIT](LICENSE)