Menu

AI

How to Build an MCP Server for a WordPress Site (Two Ways)

Two ways to give AI tools access to WordPress: register abilities for the official MCP Adapter, or build a Python server on the REST API with Application Passwords.

By: Irina ShvayaNovember 2, 2026

How to Build an MCP Server for a WordPress Site (Two Ways)

This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.

There are two good ways to build an MCP server for a WordPress site. The first runs inside WordPress: you register an "ability" in a plugin, and the official WordPress MCP Adapter exposes it as an MCP tool. The second runs outside WordPress: a small Python server calls the WordPress REST API with an Application Password.

Pick the first if you control the site's code and want WordPress to enforce its own permissions. Pick the second if you cannot install plugins, manage many sites, or want the server to live with your other tools. This guide shows both, and the Python version was run against a live public WordPress site.

Checked against: the WordPress MCP Adapter repository and its Creating Abilities guide, the REST API authentication handbook, and the Python MCP SDK docs on 2 November 2026. The Python server ran locally on 2 November 2026 with mcp 2.3.0, reading from wordpress.org/news. The PHP ability and the draft-writing tool were not run.

Key Takeaways

  • The WordPress MCP Adapter turns registered abilities into MCP tools. Abilities are private by default, and you opt each one in.
  • An external server uses the core REST API at /wp-json/wp/v2 and authenticates with an Application Password over HTTPS.
  • Make AI writes create drafts only. A person should press Publish.
  • Give the AI its own WordPress user with the lowest role that works, such as Contributor.
  • Two older options are gone: Automattic's wordpress-mcp repository is archived, and WooCommerce 11.3 removed its built-in MCP endpoint.

Which way should you build?

Way 1: Abilities + MCP AdapterWay 2: External REST API server
RunsInside WordPress (PHP)Anywhere (Python here)
NeedsPlugin install rightsAn Application Password
PermissionsWordPress permission_callback per abilityThe WordPress user's role
Endpoint/wp-json/mcp/mcp-adapter-default-server or WP-CLI over stdioYour server's stdio or URL
Best forYour own site, custom business logicHosted sites, many sites, agencies

For ready-made options, see our best MCP servers for WordPress and the WordPress MCP Adapter profile.

Way 1: Register an ability for the MCP Adapter

The adapter's README calls it the bridge between the WordPress Abilities API and MCP. It says abilities are private by default, and you set meta.public or meta.mcp.public to true to expose one. It serves HTTP clients at /wp-json/mcp/mcp-adapter-default-server, and supports WP-CLI over stdio.

This example comes from the adapter's Creating Abilities guide. Put it in a small plugin:

add_action( 'wp_abilities_api_init', function () {
    wp_register_ability( 'acme-notes/count-notes', [
        'label'        => 'Count Notes',
        'description'  => 'Counts notes with a given status.',
        'category'     => 'site',
        'input_schema' => [
            'type'       => 'object',
            'properties' => [
                'status' => [
                    'type'        => 'string',
                    'enum'        => [ 'draft', 'publish' ],
                    'description' => 'Note status to count.',
                ],
            ],
            'required'   => [ 'status' ],
        ],
        'output_schema' => [
            'type'       => 'object',
            'properties' => [
                'count' => [ 'type' => 'integer' ],
            ],
        ],
        'execute_callback'    => function ( array $input ) {
            $counts = wp_count_posts( 'post' );
            return [ 'count' => (int) ( $counts->{ $input['status'] } ?? 0 ) ];
        },
        'permission_callback' => function () {
            return current_user_can( 'read' );
        },
        'meta' => [
            'mcp' => [
                'public' => true,
                'type'   => 'tool',
            ],
        ],
    ] );
} );

What to notice:

  • input_schema is plain JSON Schema. The enum limits the model to two values, just like typed inputs in an MCP SDK.
  • permission_callback runs WordPress's own capability check for the signed-in user. This is the main advantage of Way 1. WordPress decides, not your MCP code.
  • meta.mcp.public opts the ability into MCP. The guide also says that on WordPress 6.9 and 7.0 the adapter honors meta.public for MCP exposure, and that core applies meta.public to the REST API starting in 7.1.
  • The hook is wp_abilities_api_init. The guide says categories register separately, on wp_abilities_api_categories_init.

Then install the MCP Adapter and connect a client using its WP-CLI or HTTP instructions in the repository's docs. Writing PHP abilities is the most "WordPress-native" path, and plugins can ship abilities that site owners turn on.

Way 2: Build an external server on the REST API

Prerequisites

  • Python 3.11 or newer, and Node.js 22.19+ for the Inspector.
  • A WordPress site served over HTTPS.
  • A dedicated WordPress user for the AI, with the lowest role that can do the job.
  • An Application Password for that user. The REST API handbook says WordPress has shipped Application Passwords since 5.6, and that the credentials are passed "to REST API requests served over https://" using Basic Authentication.

Install

uv init wp-mcp --python 3.12
cd wp-mcp
uv add "mcp[cli]" httpx

Minimal working server

Create server.py:

import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from html import unescape
from typing import Annotated, Any

import httpx
from pydantic import Field

from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ToolError
from mcp.types import ToolAnnotations

SITE = os.environ["WP_SITE_URL"].rstrip("/")  # e.g. https://example.com
WP_USER = os.environ.get("WP_USER")
WP_APP_PASSWORD = os.environ.get("WP_APP_PASSWORD")  # an Application Password, not the login password


@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[httpx.AsyncClient]:
    auth = (WP_USER, WP_APP_PASSWORD) if WP_USER and WP_APP_PASSWORD else None
    async with httpx.AsyncClient(base_url=f"{SITE}/wp-json/wp/v2", auth=auth, timeout=20) as http:
        yield http


mcp = MCPServer("wordpress-site", lifespan=lifespan)


async def wp(ctx: Context[httpx.AsyncClient], method: str, path: str, **kwargs: Any) -> Any:
    resp = await ctx.request_context.lifespan_context.request(method, path, **kwargs)
    if resp.is_error:
        try:
            detail = resp.json().get("message", "")
        except ValueError:
            detail = resp.text[:200]
        raise ToolError(f"WordPress returned {resp.status_code}: {detail}")
    return resp.json()


@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
async def search_posts(
    ctx: Context[httpx.AsyncClient],
    search: Annotated[str, Field(min_length=2, description="Words to search for in published posts.")],
    per_page: Annotated[int, Field(ge=1, le=20)] = 5,
) -> list[dict[str, Any]]:
    """Search published posts. Returns id, title, date and link."""
    posts = await wp(ctx, "GET", "/posts", params={"search": search, "per_page": per_page, "_fields": "id,title,date,link"})
    return [{"id": p["id"], "title": unescape(p["title"]["rendered"]), "date": p["date"], "link": p["link"]} for p in posts]


if __name__ == "__main__":
    mcp.run()

How it works:

  • One HTTP client with the site's REST base URL and Basic Auth, created once in the lifespan.
  • _fields asks WordPress to return only the fields you need, which keeps responses small.
  • unescape turns HTML entities in titles, such as ’, into normal characters.
  • Errors include the message field from WordPress's error response, so the model sees WordPress's own explanation, such as a permissions problem.

Add a draft-only write tool

@mcp.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False))
async def create_draft(
    ctx: Context[httpx.AsyncClient],
    title: Annotated[str, Field(min_length=5, max_length=120)],
    content_html: Annotated[str, Field(max_length=20000, description="Post body as simple HTML paragraphs.")],
) -> dict[str, Any]:
    """Create a DRAFT post for an editor to review. Never publishes."""
    if not (WP_USER and WP_APP_PASSWORD):
        raise ToolError("Writing needs WP_USER and WP_APP_PASSWORD on the server.")
    post = await wp(ctx, "POST", "/posts", json={"title": title, "content": content_html, "status": "draft"})
    return {"id": post["id"], "status": post["status"], "edit_link": f"{SITE}/wp-admin/post.php?post={post['id']}&action=edit"}

The status is fixed to draft in code. There is no argument the model could use to publish. The tool returns an edit link so a person can review and publish.

Test it

The run on 2 November 2026 pointed WP_SITE_URL at https://wordpress.org/news, a public WordPress site, with no credentials:

False {'result': [{'id': 21896, 'title': 'WordPress 7.1.4', 'date': '2026-10-28T17:13:02', 'link': 'https://wordpress.org/news/2026/10/wordpress-7-1-4/'}, ...]}
True Error executing tool create_draft: Writing needs WP_USER and WP_APP_PASSWORD on the server.

Search worked against the live REST API, and the write tool refused cleanly without credentials. Test drafts against a staging copy of your site, never production.

Then run the MCP Inspector CLI:

npx @modelcontextprotocol/inspector --cli \
  -e WP_SITE_URL=https://staging.example.com -e WP_USER=ai-writer -e WP_APP_PASSWORD="xxxx xxxx xxxx" \
  uv --directory /ABSOLUTE/PATH/wp-mcp run server.py \
  --method tools/list --format json

Our Inspector lesson explains the options.

Connect it to a host

For Claude Code, options go before the name and the command after --:

claude mcp add -e WP_SITE_URL=https://example.com -e WP_USER=ai-writer -e WP_APP_PASSWORD="xxxx xxxx xxxx" \
  --transport stdio wordpress -- uv run --directory /ABSOLUTE/PATH/wp-mcp server.py

For Claude Desktop, Cursor and VS Code, our MCP config generator writes the JSON.

Deploy notes

  • A dedicated user. Create a WordPress user just for the AI, so its actions show under its own name. If anything looks wrong, revoke its Application Password.
  • Lowest role. WordPress's roles page describes a Contributor as "somebody who can write and manage their own posts but cannot publish them," which pairs well with draft-only tools.
  • Staging first. Point the server at staging while you tune tool descriptions.
  • Agencies. For many client sites, run one server per site with its own credentials, so one leaked password cannot touch every site.
  • Remote deployment. If several editors share one server, add sign-in in front of it. See adding OAuth to a remote MCP server.

Pitfalls

  1. Archived plugins. Automattic's wordpress-mcp repository is archived. Use the official MCP Adapter or the REST API.
  2. WooCommerce's old endpoint. WooCommerce 11.3 removed /wp-json/woocommerce/mcp, per WooCommerce's developer blog of 21 October 2026. Old guides that use it no longer work.
  3. Using the admin login password. Application Passwords can be revoked one by one. Your real password cannot be scoped.
  4. Letting the model publish. Keep status: draft hard-coded.
  5. v1 MCP imports. from mcp.server.fastmcp import FastMCP fails on SDK v2. Use from mcp.server import MCPServer.

Next step

The free Learn MCP course covers the rest of the build path. eSEOspace builds and runs WordPress sites; if you want an MCP setup for your site's content workflow, eSEOspace MCP development can help design it. For the day-to-day workflow, read our website content updates playbook.

More in the MCP Guide

Free tools: server.json Validator · MCP Error Code Lookup

Latest research: State of MCP: October 2026

Frequently asked questions

Do I need the MCP Adapter plugin for Way 2?

No. Way 2 uses only the core REST API and an Application Password. The adapter is for exposing abilities registered inside WordPress.

Can the AI edit existing pages?

It can if you add a tool for it and the user's role allows it. Start with drafts. If you add edits, save them as revisions or drafts for review rather than changing live pages.

Is the REST API enabled on my site?

It is part of WordPress core. Some security plugins or hosts restrict it. If /wp-json/wp/v2/posts returns an error, check those settings first.

Can I use this with a page builder like Elementor?

The REST API stores post content, but page builders often keep layout in their own data. Draft tools work best for standard block editor posts.

Which way is more secure?

Both can be safe. Way 1 lets WordPress check capabilities per ability. Way 2 relies on the AI user's role. In both, keep writes narrow and drafts-only.

You Might Also like to Read

Get a FREE Website Audit

Enter your website and get a 20–30 page SEO, GEO, AEO & CRO audit within 24 hours. Completely free.

Don't have a site yet? Get in touch →

Ready to speak with a marketing expert?
Give us a call! (916) 866-7893

Websites Launched
1407
Websites Launched
Marketing Experts
29
Marketing Experts
Hours of Dedicated Work
204533
Hours of Dedicated Work
Irina S.Benjamin G.Jeanette M.David G.