Skip to content

Read-Only Isn’t Automatically Safe: The MCP Security Gap We Found in Our Own WordPress Tooling

By Jasper Frumau WordPress

A LinkedIn post from a WordPress consultant sent us back into our own AI tooling to check for the exact gap he was describing. His point, in short: connecting an AI assistant to a WordPress site now takes minutes — install a plugin, copy an MCP URL, sign in. What that connection can actually do once it’s authenticated is a completely different question, and almost nobody checks it before something goes wrong.

So we checked ours. We maintain wp-ops, an open-source MCP server that lets Claude manage client WordPress sites on our behalf — SSH access, WP-CLI, database pulls, the full range of day-to-day operations work. Anything that writes or deletes already required an explicit human approval step before it could run. Reads, though, were treated as automatically safe. That distinction turned out to be the problem.

Quick Summary: Our wp-ops MCP server already required explicit confirmation before any operation that writes, deletes, or otherwise changes a site. What it didn’t check was whether a “read-only” command was reading something sensitive. cat wp-config.php and wp config get DB_PASSWORD could both run with zero approval, because the command itself was harmless in general — even though this particular argument printed a live database password straight into the response. Found and fixed the same day, released as wp-ops 5.20.1. Nothing that used to work stopped working; the fix only closes the no-confirmation fast path for credential-shaped reads.

What “read-only” meant in our tooling

wp-ops gates every operation that changes a site behind a confirm: true flag — that part of the design already matched what security-minded WordPress consultants are asking for. Deleting a user, pulling a production database over a local one, publishing a post: all of it refuses to run until it’s been shown to a human and explicitly approved. That’s enforced in code, not left to the model’s judgment.

Reads sat on the other side of that line entirely. A short allowlist of commands — cat, grep, head, tail, ls, stat, and a couple dozen others — was treated as safe by definition, because none of those commands can delete a file or change a database row. A parallel rule applied to WP-CLI: any command whose second word was a “safe verb” like get, list, or export ran immediately too.

Both rules reasoned about the command. Neither reasoned about the argument.

The two places it broke down

cat wp-config.php on a production server. cat is about as harmless a command as exists — it prints a file and does nothing else. That’s exactly why it was on the read-only allowlist. But wp-config.php is where WordPress stores its database credentials in plain text. An AI assistant asked to “check the site’s configuration” could read that file, and the database password inside it, without a single approval prompt.

wp config get DB_PASSWORD. This one is more direct — WP-CLI’s dedicated interface onto wp-config.php’s constants, not a workaround through the filesystem. get is a generically safe verb across dozens of WP-CLI commands (wp option get, wp user get, wp post get — all genuinely harmless), so the rule that approved those also approved this one. Same result: a live database password, printed with no confirmation step, because the check only ever asked “is get a safe verb,” never “safe to get what.”

For the developers: the actual check, before and after

The SSH command allowlist, before — command-name only, no argument inspection:

const READ_ONLY_COMMANDS = new Set([
  "cat", "grep", "head", "tail", "ls", "stat", "file",
  "du", "df", "ps", "whoami", "id", "hostname", /* ...20 more */
]);

export function isReadOnlySshCommand(command: string): boolean {
  const tokens = tokenizeCommand(command);
  const base = path.basename(tokens[0]);
  return READ_ONLY_COMMANDS.has(base);
}

After — any argument matching a credential-shaped path now forces confirm: true, regardless of which command is being run:

const CREDENTIAL_PATH_PATTERNS: RegExp[] = [
  /wp-config\.php$/i, /(^|\/)\.env(\.|$)/, /(^|\/)\.ssh(\/|$)/,
  /(^|\/)id_rsa/, /(^|\/)id_ed25519/, /\.pem$/i,
  /(^|\/)authorized_keys$/, /(^|\/)\.netrc$/, /(^|\/)\.pgpass$/,
  /(^|\/)\.git-credentials$/,
];

export function isReadOnlySshCommand(command: string): boolean {
  const tokens = tokenizeCommand(command);
  const base = path.basename(tokens[0]);
  if (!READ_ONLY_COMMANDS.has(base)) return false;
  if (touchesCredentialPath(tokens)) return false; // new
  return true;
}

The WP-CLI check got a narrower, blunter fix: wp config — any verb — is now excluded from the safe-read path entirely, rather than trying to enumerate which of WordPress’s arbitrary constant names might be secrets:

const ALWAYS_CONFIRM_COMMANDS = new Set(["config"]);

export function isReadOnlyWpCommand(args: string[]): boolean {
  const [command, verbOrResource] = args;
  if (ALWAYS_CONFIRM_COMMANDS.has(command)) return false; // new
  if (SAFE_READ_VERBS.has(verbOrResource)) return true;
  return false;
}

Full diff, CHANGELOG entry, and commit history: wp-ops PR #220, released as v5.20.1.

Why “read-only” and “safe” aren’t the same claim

“Read-only” is a true statement about a command’s side effects on the system running it — it won’t delete a file or corrupt a table. It says nothing about the sensitivity of what gets read. A backup tool, a monitoring dashboard, a log analyzer, an AI assistant with file access — any of them can be entirely read-only and still hand a secret to whoever’s on the other end of the connection, simply by printing it back as output. The failure mode isn’t “the AI did something destructive.” It’s “the AI did exactly what it was allowed to do, and what it was allowed to do turned out to include something it shouldn’t have.”

That’s a useful thing to know if you’re evaluating any AI-connected WordPress tool, not just this one. A vendor telling you their integration is “read-only, so it’s safe” is answering half the question. The half that matters is what, specifically, it can read — and whether anything in that list would embarrass you if it showed up somewhere it shouldn’t.

Three questions worth asking before you connect AI to a WordPress site

  • What’s the actual list of things it can do, not the marketing description? “Manages your content” could mean “creates draft posts” or “has shell access to your server.” Ask for the specific capability list, not the summary.
  • Does a write ever run without you seeing it first? If the answer is “the AI decides,” that’s a different risk profile than “the AI proposes, you approve.” Every mutating operation in our own tooling requires the second one — enforced in code, not requested in a prompt.
  • What happens the first time it reads something it shouldn’t? Most vendors have thought hard about preventing bad writes. Fewer have asked what a “harmless” read command does when it’s pointed at wp-config.php. This post exists because we found that gap in our own answer to that question.

We wrote, in the post introducing this tooling, that safety should be a design feature rather than a prompt instruction — something enforced in code that doesn’t depend on the model behaving well. That principle held up here: the fix wasn’t “tell the AI to be more careful,” it was closing a specific, checkable gap in the code the AI runs against. The same principle is what caught the gap in the first place — the confirm-gate design meant writes were never at risk; it was reads that needed the same treatment.

This sits alongside the rest of how we think about WordPress security for client sites — server-level hardening, credential handling, and now the AI tooling that touches both. We’ve written before about the other end of that same posture: why we leave scanner traffic unblocked rather than reacting to every probe, because knowing what a scan can and can’t reach matters more than the noise itself.

Credit where it’s due: this whole review started with Remkus de Vries’s original LinkedIn post on MCP permission models — worth reading in full if this topic is relevant to your own site.

Frequently Asked Questions

  • Was any client’s actual database password exposed? No. This was found through an internal review of our own open-source tooling’s code, prompted by a discussion on LinkedIn — not through an incident, and not against a live client site. The gap existed in the code path; nothing exploited it.
  • Does this affect other wp-ops users? Anyone running wp-ops’s MCP server before v5.20.1 had the same gap. Upgrading closes it; the CHANGELOG and PR linked above have the full detail.
  • Does the fix block legitimate use? No. Reading wp-config.php or a database credential still works — it just requires the same explicit “yes, do that” approval every other sensitive operation in the tool already requires. Nothing that used to be possible stopped being possible.
  • Is wp-ops the only tool with this kind of gap? We can’t speak to other tools’ internals, which is the point of the three questions above — ask any vendor connecting AI to your WordPress site the same thing we asked ourselves.

Done Managing Your Own Server?

We offer managed WordPress hosting built on Trellis — Nginx, PHP 8.3, Redis, automated deployments via Ansible, and Bedrock structure on Hetzner EU. No shared hosting, no page builders, no surprises.

  • Trellis + Bedrock on Hetzner EU (Frankfurt / Helsinki)
  • Nginx + FastCGI caching + Redis object cache
  • Automated deployments via Ansible, SSL via Let’s Encrypt
  • From €49/month — or €65/hour for one-off server work

Leave a Reply

Your email address will not be published.