wp-remote-bridge
by seangoogoo
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)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues