← Back to Home

WordPress Deep Dive

MCPWordPressSecurityCloudflare

Once you install mcp-adapter, your AI can directly read and write your site — but you also just exposed /wp-json/mcp/mcp-adapter-default-server to the public internet. That REST endpoint accepts any request carrying a WordPress Application Password, including those from bots, scanners, and prompt injection attackers.

If you don't put a Cloudflare Zero Trust layer in front, mcp-adapter is just a nicely packaged, easily enumerable REST API.

This article picks up from the June 17 Cloudflare Tunnel zero-port pattern and the June 30 WordPress 7.0 MCP integration walkthrough, and serves as the security closer for the AI integration layer. I cover the 6 real traps I hit, plus how to use Cloudflare Tunnel + Zero Trust Service Auth + mcp-adapter's own ability allowlist to compress the MCP endpoint down to "only my local Claude Code can call it."

Architecture: From Default mcp-adapter Exposure to Zero Trust

By default, mcp-adapter uses the WordPress REST API route:

/wp-json/mcp/mcp-adapter-default-server

This endpoint authenticates with WordPress Application Password. Anyone with a username + Application Password (including bots) can call every Ability you've registered — things like site-audit/find-broken-links, content/bulk-update-meta, and any custom Ability you've built.

After wiring it through Cloudflare Tunnel, the traffic path becomes:

Claude Code (local) → Cloudflare Edge (Zero Trust self-serve login) → cloudflared (loopback) → 127.0.0.1/wp-json/mcp

The key changes are:

1. **No public TCP port is listening** — cloudflared is outbound-only

2. HTTP layer hits Zero Trust first — requests without a Service Token or email OTP get 403'd at Cloudflare's edge

3. REST layer still requires Application Password — two layers of auth

4. mcp-adapter 0.5.0 ability allowlist — only expose the Abilities you actually need

Prerequisites

Confirm your environment before starting:

Verify:

# Verify mcp-adapter is installed
wp plugin list --allow-root | grep mcp-adapter
# Expected: mcp-adapter   0.5.0   active   ...

# Verify the Abilities API is registered
wp eval 'if ( function_exists( "wp_register_ability" ) ) { echo "OK"; } else { echo "NEED 6.9+"; }' --allow-root

# Verify cloudflared is running
ps aux | grep -E "[c]loudflared"
# Expected: at least one line containing "tunnel"

Core Deployment

Step 1: Restrict the mcp-adapter Default Server to Read-Only Abilities

The mcp_adapter_server_config filter introduced in mcp-adapter 0.5.0 lets you trim the default server's exposed ability set (PR #217 merged 2026-06-17).

Add this to a custom plugin or mu-plugin:

Activate the mu-plugin, then verify:

curl -s -u "your_user:your_app_password" \
  "https://your-domain.com/wp-json/mcp/mcp-adapter-default-server" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools[].name'
# Expected output should only include tools named site-audit/* or content/read-*

If content/bulk-update-meta or any write tool shows up, the filter didn't run — 99% of the time that's a mu-plugin load order issue. Move the file to /wp-content/mu-plugins/ instead of dropping it in the theme's functions.php.

Step 2: Wire mcp.example.com Into Your Existing Tunnel

If you already have cloudflared running from the June 17 setup, don't open a new tunnel for MCP — just add a route:

# ~/.cloudflared/config.yml (append, don't overwrite)
tunnel: 
credentials-file: /root/.cloudflared/.json

ingress:
  # Existing rules stay as-is...
  - hostname: mcp.example.com
    service: http_status:404
    originRequest:
      noTLSVerify: true
  - service: http_status:404

Then add a public hostname in the Cloudflare Zero Trust dashboard:

1. Zero Trust → Networks → Tunnels → select your tunnel → Configure → Public Hostname

2. Subdomain: mcp

3. Domain: example.com

4. Service Type: HTTP

5. URL: localhost

Note the http_status:404 placeholder first — in the next step you'll add a Zero Trust policy, and the tunnel must NOT forward to wp-json before that policy exists.

Restart cloudflared:

sudo systemctl restart cloudflared
sudo systemctl status cloudflared

Step 3: Add a Zero Trust Self-Hosted App for mcp.example.com

In the Zero Trust dashboard:

1. Access → Applications → Add an application → Self-hosted

2. Name: WordPress MCP (Personal)

3. Session duration: 24 hours (don't pick Always — Service Tokens will silently fail)

4. Application domain: mcp.example.com

5. Identity providers: pick One-time PIN (email verification code)

Add two Policies:

Policy 1 - My Claude Code Uses Service Auth

Name:  Local Claude Code
Action: Allow
Session duration: 24 hours
Apply to: Service Auth - 

Policy 2 - Browser Debug Uses OTP

Name:  Browser Debug
Action: Allow
Session duration: 1 hour
Apply to: Emails - your-email@example.com

The Service Auth policy MUST come first (it's more specific). Anything that doesn't match either policy is denied.

Step 4: Embed the Service Token in Your Claude Code MCP Config

Cloudflare Service Tokens authenticate with a Client ID + Client Secret long-lived credential (not a JWT, and they can be revoked).

Generate: Access → Service Auth → Create Service Token → name it local-claude-code → grab the Client ID and Client Secret.

In Claude Code's MCP config:

{
  "mcpServers": {
    "wordpress-mcp": {
      "url": "https://mcp.example.com/wp-json/mcp/mcp-adapter-default-server",
      "headers": {
        "CF-Access-Client-Id": "",
        "CF-Access-Client-Secret": "",
        "Authorization": "Basic " + base64("your_wp_user:your_app_password")
      }
    }
  }
}

Or via claude mcp add-json:

claude mcp add-json wordpress-mcp '{
  "url": "https://mcp.example.com/wp-json/mcp/mcp-adapter-default-server",
  "headers": {
    "CF-Access-Client-Id": "xxx.service-token",
    "CF-Access-Client-Secret": "xxx",
    "Authorization": "Basic '"$(echo -n 'your_user:your_app_password' | base64)""'"
  }
}'

Verify:

claude mcp list
# Expected: wordpress-mcp: https://mcp.example.com/wp-json/mcp/mcp-adapter-default-server  (14 tools)

Step 5: Enable mcp-adapter Observability for Audit Logging

The 0.5.0 observability handler invokes record_event() after each tools/call. Hook it into WordPress's debug log:

add_action( 'mcp_adapter_observability_event', function( $handler, $event, $payload ) {
    if ( 'mcp.request' !== $event ) {
        return;
    }
    if ( 'success' !== ( $payload['status'] ?? 'unknown' ) ) {
        error_log( sprintf(
            '[MCP] %s user=%s tool=%s',
            $payload['status'],
            $payload['user'] ?? 'anon',
            $payload['method'] ?? 'unknown'
        ) );
    }
}, 10, 3 );

Write to /var/log/nginx/wordpress-access.log or wp-content/debug.log (logrotate 7 days retention is fine).

Pitfalls: 6 Real Production Traps

Trap 1: mcp-adapter Default Server Exposes Every Registered Ability

**Symptom**: Right after installing mcp-adapter, tools/list returns every registered Ability, including destructive ones like plugin/install and user/create.

**Root cause**: The default server includes every registered Ability by default. The mcp_adapter_server_config filter was only introduced in 0.5.0.

**Fix**: You MUST install the mu-plugin from Step 1. If you skip this step, mcp-adapter is just a nicely packaged, easily enumerable REST API — anyone with an Application Password (including prompt injection attackers) can call plugin/install and drop a webshell on your site.

Trap 2: Wrong Application Password Setup Triggers 401 Storms on /wp-json/mcp

**Symptom**: nginx access log shows massive 401s, Claude Code keeps retrying, and mcp-adapter observability is full of auth_failed.

**Root cause**: WordPress 6.9+ introduced the application_password_is_api_request filter — Application Password requests must hit specific REST paths AND HTTP Basic Auth must use user_login, not user_email.

Fix:

// Don't use email, use login
$auth = 'Basic ' . base64_encode( 'my_wp_login:abcd EFGH ijkl MNOP qrst UVWX' );
// Note: keep the spaces inside the Application Password

On WordPress 6.8 and below, using the email works. On 7.0+ it returns 401. Verify:

curl -s -o /dev/null -w "%{http_code}" -u "your_login:your_app_pwd" \
  "https://your-domain.com/wp-json/mcp/mcp-adapter-default-server" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Must return 200. 401 means the username format is wrong.

Trap 3: cloudflared → 127.0.0.1 over HTTPS Triggers 526 Invalid SSL

**Symptom**: Right after deployment, https://mcp.example.com/wp-json/mcp/... returns 526 Invalid SSL Certificate.

**Root cause**: cloudflared forwarding to http://localhost is fine, but your nginx is listening on 443 and only accepts SNI for example.com. Requests for mcp.example.com get a 526 from nginx.

Fix: Pick one of two paths:

Path A (recommended): Use a Cloudflare Origin Certificate

1. Cloudflare dashboard → SSL/TLS → Origin Server → Create Certificate

2. Cover *.example.com and example.com

3. Install the cert and key on nginx, enable HTTPS for mcp.example.com

4. Change cloudflared to forward https://localhost:443 (keep noTLSVerify: true)

Path B: cloudflared with noTLSVerify + Force HTTP

ingress:
  - hostname: mcp.example.com
    service: http://127.0.0.1:80
    originRequest:
      noTLSVerify: true
      httpHostHeader: mcp.example.com

And configure nginx with a port-80 vhost for mcp.example.com, not port 443.

Trap 4: Zero Trust Policy Order — Service Auth Gets Stealthered by OTP

**Symptom**: Service Token requests get bounced to Cloudflare's OTP login page (/cdn-cgi/access/login). Claude Code receives HTML instead of JSON and fails to parse.

Root cause: Access policies match in order. Your OTP policy is sitting before the Service Auth policy. Email matches are wider than named Service Auth policies, so they win.

Fix: Drag the Service Auth policy to the top. Verify the order:

# Direct call with Service Token
curl -s -H "CF-Access-Client-Id: xxx" -H "CF-Access-Client-Secret: yyy" \
  "https://mcp.example.com/wp-json/mcp/mcp-adapter-default-server" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Must return JSON directly, NOT a 302 redirect to the login page

If you still get 302, the policy order is wrong. Or your Service Token is named default — Cloudflare doesn't allow reserved words as Service Token names.

Trap 5: Abilities API Detection Fails — mcp-adapter Starts Silently Empty

**Symptom**: After installing mcp-adapter, tools/list returns an empty array, but mcp-adapter-default-server is registered. The site is on WordPress 6.9+, wp_register_ability exists, but wp_get_abilities() returns [].

**Root cause**: Ability registration hook timing. Most plugins register on the init hook, but mcp-adapter starts collecting on plugins_loaded — registration happens AFTER collection.

Fix: Move the registration hook earlier:

// Don't use init
add_action( 'plugins_loaded', function() {
    wp_register_ability( 'site-audit/find-broken-links', [...] );
}, 5 ); // Priority 5, earlier than the default 10

mcp-adapter 0.5.0's PR #196 added a mcp_adapter_default_server_abilities filter on the default server. In a pinch, manually feed it an Ability list from a mu-plugin:

add_filter( 'mcp_adapter_default_server_abilities', function( $ids ) {
    return [
        'site-audit/find-broken-links',
        'content/read-meta',
        'content/read-taxonomies',
    ];
} );

Trap 6: Old cloudflared (2024.x) Doesn't Support Streamable HTTP

**Symptom**: Local Claude Code tries streamable HTTP for mcp-adapter, cloudflared log fills with 400 Bad Request: invalid HTTP version, but SSE works.

Root cause: cloudflared 2024.x still proxies over HTTP/1.1. HTTP/2 stream support only landed in early 2025 builds. mcp-adapter 0.5.0's streamable transport defaults to HTTP/2.

Fix:

cloudflared --version
# Must be 2025.4.0 or newer

If your distro's repo has an old cloudflared (Ubuntu 22.04 ships 2024.8.0 by default), use Cloudflare's official apt source:

curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
  | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
  | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflared

Hardening: Make mcp-adapter Truly Yours

After completing Steps 1-5, three more things you can tighten:

1. Add an IP Allowlist to the Default Server (Defense in Depth)

Cloudflare Tunnel's loopback IP is 127.0.0.1, but other services on the same machine might share that loopback. Add a localhost-only allowlist to your mcp.example.com nginx vhost:

location /wp-json/mcp/ {
    allow 127.0.0.1;
    allow ::1;
    deny all;

    try_files $uri $uri/ /index.php?$args;
}

Even if someone bypasses Cloudflare and hits nginx directly, the public can't reach it.

2. Rate-Limit the mcp-adapter Endpoint

mcp-adapter uses the WordPress REST API, but you can give /wp-json/mcp/ its own limit_req on nginx:

limit_req_zone $binary_remote_addr zone=mcp_limit:10m rate=10r/m;

location /wp-json/mcp/ {
    limit_req zone=mcp_limit burst=20 nodelay;
    try_files $uri $uri/ /index.php?$args;
}

10 r/m is plenty for Claude Code's daily use, burst=20 prevents accidental 429s.

3. Lock Down Transport Methods via mcp-adapter 0.5.0

The 0.5.0 mcp_adapter_server_config filter supports allowed_methods:

$config['allowed_methods'] = [ 'tools/list', 'tools/call' ];
// Don't allow 'resources/read', 'prompts/get' — mcp-adapter doesn't need them

Reduces attack surface.

Wrap-up

June 17 covered Cloudflare Tunnel to give your site zero public ports. June 30 covered mcp-adapter so AI can directly read and write the site. This article is the security bridge between them: mcp-adapter's default endpoint must pass through Zero Trust, Abilities must be allowlisted, cloudflared must be on a recent version, and observability must log to disk.

Full checklist:

  • [ ] Step 1: `mcp_adapter_server_config` filter to restrict read-only Abilities
  • [ ] Step 2: cloudflared ingress adds `mcp.example.com`
  • [ ] Step 3: Zero Trust self-hosted app + Service Auth policy
  • [ ] Step 4: Service Token embedded in Claude Code MCP headers
  • [ ] Step 5: observability handler writes error logs to debug.log
  • [ ] Hardening: nginx IP allowlist + rate-limit + transport method lockdown

mcp-adapter 0.5.0 (2026-06 release) and its observability subsystem are the lynchpin of this architecture. Earlier versions had no mcp_adapter_observability_event hook — every MCP call was a black box.

Next direction: WordPress 7.0 real-time collaboration (HTTP polling sync provider + multi-user edit conflict resolution), or a complete mcp-adapter 0.5.0 benchmark (latency comparison across different Abilities).

👉 Join MiniMax Token Plan: AI coding acceleration for businesses

👉 Join Zhipu Coding Plan: GLM-4.6/GLM-5 coding packages, China-stable, pay-per-token unlimited

👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation

📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews

🔗 Recommended Tools

These are carefully selected tools. Using our affiliate links supports us to keep producing quality content:

☁️ DigitalOcean Cloud ⚡ Vultr VPS ⭐ MiniMax Token Plan 🧩 Zhipu Coding Plan 🎁 Zhipu 20M Tokens Gift 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books
← Back to Home