Skip to content
AR logo
← Back to Blog
MCPWordPressClaudeSSHWP-CLI

Building an MCP Server for WordPress Site Management

·10 min read

Why an MCP Server for WordPress?

Managing 30+ WordPress sites means a lot of repetitive SSH sessions, WP-CLI commands, and database operations. The Model Context Protocol (MCP) lets Claude directly call tools — so instead of copy-pasting commands, Claude can run them.

The result: a natural language interface for WordPress operations. "Check if the staging site has the latest plugin versions" becomes a single request, not a five-minute SSH session.

The Architecture

MCP servers are Node.js processes that communicate with Claude via stdio. The server exposes tools (functions with JSON Schema definitions) that Claude can call with structured arguments.

// Tool definition
{
  name: 'wp_cli',
  description: 'Run a WP-CLI command on a remote site',
  inputSchema: {
    type: 'object',
    properties: {
      site: { type: 'string', description: 'Site identifier' },
      command: { type: 'string', description: 'WP-CLI command (without wp prefix)' },
    },
    required: ['site', 'command'],
  },
}

SSH in a Serverless Context: Why ssh2

The obvious approach — child_process.spawn('ssh', [...]) — doesn't work in environments without a system SSH binary (CI, some serverless runtimes). The ssh2 package is a pure-JS SSH2 implementation with no native binaries.

import { Client } from 'ssh2';

async function sshExec(host, command) {
  return new Promise((resolve, reject) => {
    const conn = new Client();
    let output = '';
    conn.on('ready', () => {
      conn.exec(command, (err, stream) => {
        stream.on('data', (data) => output += data);
        stream.on('close', () => { conn.end(); resolve(output); });
      });
    }).connect({ host, username: 'root', privateKey: fs.readFileSync(keyPath) });
  });
}

WP-CLI Auto-Detection

WP-CLI isn't always at /usr/local/bin/wp. On Kinsta, it's at a different path. The auto-detection checks common paths and falls back to searching the PATH:

const WP_CLI_PATHS = [
  '/usr/local/bin/wp',
  '/usr/bin/wp',
  '~/.composer/vendor/bin/wp',
];

async function findWpCli(conn) {
  for (const path of WP_CLI_PATHS) {
    const result = await sshExec(conn, `test -f ${path} && echo found`);
    if (result.includes('found')) return path;
  }
  return 'wp'; // hope it's in PATH
}

Parsing WP-CLI JSON Output

WP-CLI's --format=json output is usually clean, but plugins sometimes write to stdout during hooks, prepending garbage before the JSON. The fix:

function parseWpCliJson(output) {
  const jsonStart = output.indexOf('[');
  const jsonStartObj = output.indexOf('{');
  const start = Math.min(
    jsonStart === -1 ? Infinity : jsonStart,
    jsonStartObj === -1 ? Infinity : jsonStartObj
  );
  if (start === Infinity) throw new Error('No JSON in output');
  return JSON.parse(output.slice(start));
}

Safety Gates

An MCP server with direct SSH access to production sites needs safety guardrails. Every write operation goes through a hook that checks a .active-migration file — if it doesn't exist, write operations are blocked.

Read operations (SSH exec with grep/cat/ls) are always allowed. Write operations require explicit unlock.