How to Build a WordPress AI Chatbot with MCP and Claude

Most WordPress chatbots are glorified FAQ widgets. You paste in some intents, train a few phrases, and it answers "What are your shipping rates?" with a canned reply. That works until a customer asks something that requires actual data from your site, your CRM, or your order history.
That is the gap MCP closes. The Model Context Protocol gives Claude (or any compatible model) a structured way to call tools you define. Point those tools at WordPress, and your WordPress AI chatbot stops guessing. It starts querying real posts, real products, real user records, with permissions you control.
I have built this pattern a few times now, including MCP servers for internal automation at AKIJ iBOS. This is the version I would ship today for a WordPress site. It is a WordPress AI chatbot MCP setup that runs on your own server, keeps your API keys out of the browser, and stays maintainable six months later.
Why MCP Beats a Plain API Wrapper
The obvious approach is a PHP file that calls an LLM API with a system prompt and dumps the response into a chat widget. It works. I have done it. It also falls apart the moment you want the bot to do anything dynamic.
Here is the difference in practice.
| Approach | What the bot can do | Where it breaks |
|---|---|---|
| Prompt-only wrapper | Answer from a static knowledge dump | Stale content, no live data, hallucinated product details |
| Function calling in PHP | Call a fixed set of PHP functions | Every new capability means editing the chat endpoint |
| MCP server | Call any tool you register, from any client | Needs a separate service to run and secure |
With MCP, the chatbot client and the tool server are separate concerns. Your WordPress plugin handles the chat UI and the conversation loop. A Node.js MCP server exposes tools like search_posts, get_product, or create_lead. Adding a new capability means adding a tool, not rewriting the chat logic.
That separation is also what makes it testable. I can hit the MCP server with Claude Desktop during development and confirm the tools behave before a single line of PHP touches production. If you are new to the protocol itself, my overview of the role of MCP in modern AI automation stacks is a good place to start.
The Architecture I Use
Three pieces, and I keep them deliberately dumb:
- WordPress plugin (PHP): renders the chat widget, stores conversation history, proxies requests to the MCP server. Never holds the Anthropic API key in the browser.
- MCP server (Node.js): exposes tools, runs the Claude API call, handles the tool-use loop.
- WordPress REST API: the data layer the MCP tools read from, using an application password scoped to a low-privilege user.
The flow looks like this:
Browser widget -> WP REST proxy -> MCP server -> Claude API
|
v
WP REST API (tools)
Keep the API key in the MCP server's environment. Keep the MCP server on localhost or a private network. If it is publicly reachable, put a shared secret in front of it at minimum.
Step 1: Create a Scoped WordPress User for the Bot
Do not hand your MCP server an admin account. Create a dedicated user with the editor role, or better, a custom role that can only read posts and products.
Then generate an application password under Users, Profile. Copy it once, because WordPress will not show it again.
Store the credentials in the MCP server's .env:
WP_BASE_URL=https://example.com
WP_USER=chatbot-service
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-5
MCP_SHARED_SECRET=some-long-random-string
Before you go further, confirm outbound HTTPS from the WordPress host actually works. Some managed hosts and locked-down VPS images block outbound requests by default, and you will spend an hour debugging a firewall instead of your code.
Step 2: Build the MCP Server
I use the official MCP SDK with Node. The API surface has moved around between releases, so check the official MCP SDK examples and match the version you install before copying anything below.
The key idea is to define your tool schemas once and share them between the MCP server and the Claude tool-use loop. That way the model sees exactly the same interface the MCP server advertises, and you never drift.
// tools.js
import { z } from "zod";
const WP = process.env.WP_BASE_URL;
const AUTH = "Basic " + Buffer.from(
`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`
).toString("base64");
async function wpGet(path) {
const res = await fetch(`${WP}/wp-json/wp/v2/${path}`, {
headers: { Authorization: AUTH }
});
if (!res.ok) throw new Error(`WordPress returned ${res.status}`);
return res.json();
}
// Tool schema definitions, the single source of truth.
export const toolSchemas = [
{
name: "search_posts",
description: "Search published WordPress posts by keyword. Returns titles and excerpts.",
input_schema: {
type: "object",
properties: {
query: { type: "string", description: "The keyword or phrase to search for." },
perPage: { type: "number", minimum: 1, maximum: 10, default: 5 }
},
required: ["query"]
}
},
{
name: "get_post_by_slug",
description: "Fetch a single published post by its slug. Returns the full rendered content.",
input_schema: {
type: "object",
properties: {
slug: { type: "string", description: "The post slug, e.g. 'hello-world'." }
},
required: ["slug"]
}
}
];
// Runtime validation schemas, kept next to the JSON schemas on purpose.
const zodSchemas = {
search_posts: z.object({
query: z.string().min(1),
perPage: z.number().min(1).max(10).default(5)
}),
get_post_by_slug: z.object({
slug: z.string().min(1).regex(/^[a-z0-9-]+$/i)
})
};
// The single entry point the model calls. Validates, dispatches, formats.
export async function runTool(name, input) {
const schema = zodSchemas[name];
if (!schema) {
return `Unknown tool: ${name}`;
}
const parsed = schema.safeParse(input);
if (!parsed.success) {
return `Invalid input for ${name}: ${parsed.error.message}`;
}
try {
if (name === "search_posts") {
const { query, perPage } = parsed.data;
const posts = await wpGet(
`posts?search=${encodeURIComponent(query)}&per_page=${perPage}`
);
const text = posts
.map(p => `# ${p.title.rendered}\n${p.excerpt.rendered}`)
.join("\n\n");
return text || "No results.";
}
if (name === "get_post_by_slug") {
const [post] = await wpGet(`posts?slug=${encodeURIComponent(parsed.data.slug)}`);
return post ? post.content.rendered : "Not found.";
}
} catch (err) {
return `Tool ${name} failed: ${err.message}`;
}
return `Unhandled tool: ${name}`;
}
Now wire those schemas into the MCP server itself. Notice how the MCP registration delegates straight to runTool, so there is exactly one code path for executing a tool.
// mcp-server.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { runTool } from "./tools.js";
const server = new McpServer({ name: "wordpress-tools", version: "1.0.0" });
server.tool(
"search_posts",
"Search published WordPress posts by keyword",
{ query: z.string(), perPage: z.number().min(1).max(10).default(5) },
async (args) => {
const text = await runTool("search_posts", args);
return { content: [{ type: "text", text }] };
}
);
server.tool(
"get_post_by_slug",
"Fetch a single post by its slug",
{ slug: z.string() },
async (args) => {
const text = await runTool("get_post_by_slug", args);
return { content: [{ type: "text", text }] };
}
);
await server.connect(new StdioServerTransport());
Three things I want you to notice. First, every tool returns plain text, not raw JSON. Claude reasons better over formatted text, and you control the shape. Second, every fetch is wrapped in try/catch and returns a readable error string, so a network blip becomes a message the model can reason about instead of an unhandled rejection that kills the process. Third, the tool descriptions matter more than you think. Vague descriptions are the number one reason a model picks the wrong tool.
Test it with Claude Desktop before touching WordPress. Add it to your MCP config and ask it to search your site. If that works, the hard part is done. For a deeper walkthrough of the server side, my Node.js MCP server tutorial covers the full build step by step.
Step 3: The Claude Tool-Use Loop
Your MCP server also needs an HTTP endpoint the WordPress plugin can call. Here is the core loop with the Anthropic SDK. Pull the model ID from an environment variable rather than hardcoding it, Anthropic rotates model IDs on their own release cadence, and you do not want a deploy to ship a stale string.
import Anthropic from "@anthropic-ai/sdk";
import { toolSchemas, runTool } from "./tools.js";
const anthropic = new Anthropic();
const MODEL = process.env.ANTHROPIC_MODEL;
if (!MODEL) throw new Error("ANTHROPIC_MODEL is not set");
const MAX_TOOL_ITERATIONS = 5;
async function chat(userMessage, history) {
const messages = [...history, { role: "user", content: userMessage }];
let response = await anthropic.messages.create({
model: MODEL,
max_tokens: 1024,
tools: toolSchemas,
messages
});
let iterations = 0;
while (response.stop_reason === "tool_use") {
if (++iterations > MAX_TOOL_ITERATIONS) {
return "I couldn't finish that request. Try rephrasing it.";
}
const toolUse = response.content.find(b => b.type === "tool_use");
const result = await runTool(toolUse.name, toolUse.input);
messages.push({ role: "assistant", content: response.content });
messages.push({
role: "user",
content: [{ type: "tool_result", tool_use_id: toolUse.id, content: result }]
});
response = await anthropic.messages.create({
model: MODEL,
max_tokens: 1024,
tools: toolSchemas,
messages
});
}
return response.content.find(b => b.type === "text")?.text ?? "";
}
Cap the loop. I use a counter that bails after five iterations. Without it, a badly described tool can send the model into a cycle that burns tokens and never returns. Check the Anthropic tool-use documentation for the current request and response schema, since the SDK surface changes between versions.
Step 4: The WordPress Plugin
You need three files: a plugin header, a REST proxy route, and a small JS widget. Skip the widget library, a plain div and fetch call is enough.
There are four things this endpoint has to get right: a nonce to prove the request came from your page, a CORS policy that only allows your own origin, a per-IP rate limit, and a server-generated session token so the client never invents its own conversation ID.
<?php
/**
* Plugin Name: MCP Chat
*/
// Server-side session creation. The client asks for a token, the server
// generates one and hands it back. Never trust a client-supplied session ID.
add_action('rest_api_init', function () {
register_rest_route('mcp-chat/v1', '/session', [
'methods' => 'POST',
'callback' => 'mcp_chat_new_session',
'permission_callback' => 'mcp_chat_can_post',
]);
register_rest_route('mcp-chat/v1', '/message', [
'methods' => 'POST',
'callback' => 'mcp_chat_proxy',
'permission_callback' => 'mcp_chat_can_post',
]);
});
function mcp_chat_new_session(WP_REST_Request $req) {
$token = bin2hex(random_bytes(32));
set_transient('mcp_sess_' . $token, [], 30 * MINUTE_IN_SECONDS);
return ['token' => $token];
}
function mcp_chat_can_post(WP_REST_Request $req) {
// 1. Nonce check, proves the request came from a page we rendered.
$nonce = $req->get_header('X-WP-Nonce');
if (!$nonce || !wp_verify_nonce($nonce, 'wp_rest')) {
return new WP_Error('bad_nonce', 'Invalid or missing nonce', ['status' => 403]);
}
// 2. Per-IP rate limit. See the note below about proxies.
$ip = mcp_chat_client_ip();
$key = 'mcp_rl_' . md5($ip);
$hits = (int) get_transient($key);
if ($hits >= 20) {
return new WP_Error('rate_limited', 'Too many requests', ['status' => 429]);
}
set_transient($key, $hits + 1, MINUTE_IN_SECONDS);
return true;
}
/**
* Proxy-aware IP detection.
*
* REMOTE_ADDR is the only value an attacker cannot spoof directly, but
* behind a reverse proxy or CDN it is the proxy's IP, which breaks per-IP
* rate limiting. X-Forwarded-For is client-controlled unless your proxy
* overwrites it. Only trust it when you know your proxy does.
*
* Set MCP_TRUST_PROXY to true in wp-config.php ONLY if your host or CDN
* is guaranteed to rewrite X-Forwarded-For. Otherwise, leave it false and
* accept that rate limiting is per-proxy, not per-client.
*/
function mcp_chat_client_ip() {
$trust_proxy = defined('MCP_TRUST_PROXY') && MCP_TRUST_PROXY === true;
if ($trust_proxy && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$forwarded = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$candidate = trim($forwarded[0]);
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
return $candidate;
}
}
return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}
function mcp_chat_proxy(WP_REST_Request $req) {
$session = sanitize_text_field($req->get_param('session'));
$history = mcp_chat_get_session($session);
$res = wp_remote_post('http://127.0.0.1:8787/chat', [
'timeout' => 30,
'headers' => [
'Content-Type' => 'application/json',
'X-MCP-Secret' => defined('MCP_SHARED_SECRET') ? MCP_SHARED_SECRET : '',
],
'body' => wp_json_encode([
'message' => sanitize_text_field($req->get_param('message')),
'session' => $session,
'history' => $history,
]),
]);
if (is_wp_error($res)) {
return new WP_Error('mcp_down', 'Chat unavailable', ['status' => 503]);
}
return json_decode(wp_remote_retrieve_body($res), true);
}
function mcp_chat_get_session($token) {
$key = 'mcp_sess_' . $token;
$history = get_transient($key);
if ($history === false) {
// Unknown or expired token. Return empty; the client should request a new one.
return [];
}
return $history;
}
A few non-negotiables here. The permission_callback enforces a nonce and a per-IP rate limit, 20 requests per minute, adjust to taste, because the old __return_true meant anyone could hammer the endpoint. Define MCP_SHARED_SECRET in wp-config.php rather than reading it from getenv(), since many hosts do not expose environment variables to PHP-FPM:
// wp-config.php
define('MCP_SHARED_SECRET', 'some-long-random-string');
define('MCP_TRUST_PROXY', false); // set true only if your proxy rewrites X-Forwarded-For
And never return raw tool output to the browser, only the final assistant message.
CORS and the widget
The widget script is enqueued on your own site, so it hits the REST route same-origin and WordPress's default REST CORS behavior is fine, do not add a wildcard Access-Control-Allow-Origin: * header. If you ever need to call the endpoint from a different origin (a separate marketing site, a mobile app), add the origin explicitly to the rest_allowed_cors_headers and rest_send_cors_headers filters, and keep the nonce requirement in place. A wildcard origin plus a nonce is a contradiction: the nonce is only valid on your site, so a wildcard just opens the door for CSRF-style probing.
If you would rather not hand-roll the plugin plumbing, it is worth understanding the trade-offs first, my breakdown of a custom WordPress plugin vs an off-the-shelf plugin covers the cost-benefit honestly.
Security and Permissions, the Part Everyone Skips
MCP tools are remote code execution with a friendly name. Treat them that way.
- Scope the WordPress user to the minimum capability the tools need. Read-only tools should use a read-only user.
- Validate every tool input with Zod or an equivalent before it reaches WordPress. The
runToolfunction above is the choke point, keep it that way. - Never expose a tool that writes to the database without an explicit allowlist of fields.
- Log every tool call with the user ID, timestamp, and arguments. When something goes wrong, this is your only evidence.
- Put the MCP server behind a private network. If it must be public, require the shared secret and rotate it.
- Generate session tokens server-side with
random_bytes(). Never accept a client-supplied session ID, or you have handed an attacker the ability to read other users' conversations.
If you are building this for a client, put the logging in writing in the contract. Data handling questions come up in procurement, and having an answer ready saves a week of back and forth. The same discipline applies to any agent you run in production, see my guide to securing AI agents in production for the broader playbook.
Performance Notes from Production
The naive version is slow. Three fixes that matter:
- Cache tool results. A
search_postscall for the same query does not need to hit WordPress every time. Cache for five minutes in Redis or a transient. - Stream responses. Anthropic supports streaming, and users tolerate a slow answer far better when tokens appear as they arrive.
- Trim your context. Do not dump full post content into the prompt. Send excerpts, and let the model call
get_post_by_slugif it needs more.
One more thing: run the MCP server close to WordPress. If they sit in different regions, every tool call pays the round trip, and a chat that makes three tool calls pays it three times. Same VPS is the simplest answer.
Common Mistakes
- Putting the API key in the plugin. I have inherited two sites where the Anthropic key was sitting in a
wp_optionsrow, readable by anyone with database access and, in one case, echoed into the page source by a debug plugin. Rotate immediately if you find this. - Hardcoding the model ID. Anthropic retires model IDs on their own schedule. Read
ANTHROPIC_MODELfrom the environment so a model change is a config edit, not a code deploy. - Trusting
X-Forwarded-Forblindly. If you are not behind a proxy that rewrites it, an attacker can spoof their IP and walk straight past your rate limit. Default toREMOTE_ADDRand opt in to proxy trust explicitly. - One giant tool. A
do_everythingtool confuses the model. Split it. - No loop cap. I watched a staging bot hit 40 tool iterations on a single question because two tool descriptions overlapped. That was roughly $3 for one reply. Cap it at five.
- Ignoring the system prompt. Tell the model what it is, what it cannot do, and to say "I don't know" instead of inventing.
- Testing only happy paths. Ask the bot about a post that does not exist. Ask it to delete something. Watch what happens.
When This Is Worth It
If you need a static FAQ bot, use a plugin and skip all of this. The MCP route earns its complexity when the bot needs live data, when you want to add capabilities without touching the chat layer, or when you are building automation that other clients (Claude Desktop, Cursor, your own dashboards) can reuse.
That last point is the one I keep coming back to. The MCP server you build for a chatbot is the same server your internal tools can call next quarter. You are not building a chatbot. You are building a capability layer.
If you want help designing that layer for a WordPress site or a custom Next.js app, or you are comparing candidates and want a second opinion on architecture, get in touch. I do this daily.
Frequently Asked Questions
Do I need a separate server to run an MCP server for WordPress? You can run it on the same VPS as WordPress if the host allows long-running Node processes. Shared hosting usually does not, so plan for a small separate instance. Keep it on localhost or a private network and never expose it directly to the internet without a shared secret.
Can I use this with OpenAI instead of Claude? Yes. The MCP server itself is model-agnostic, so the tools stay the same. You only swap the client loop that calls the model. I use Claude by default because its tool-use behavior is predictable, but the architecture does not lock you in.
How much does a WordPress AI chatbot like this cost to run? Costs come from two places: model tokens and hosting. A small Node instance is a few dollars a month. Token cost depends on traffic and how much content you stuff into the prompt. Caching tool results and trimming context are the two biggest levers.
Is this safe for a client site with customer data? It can be, if you scope the WordPress user tightly, log every tool call, and avoid tools that write to the database without an allowlist. I would not ship it without the logging, especially for clients in regulated industries.
Can I skip MCP and just use a WordPress chatbot plugin? For static FAQ answers, yes, and you should. MCP makes sense when the bot needs live data from your site, or when you want to reuse the same tools across other clients and internal automations later.
Frequently asked questions
Do I need a separate server to run an MCP server for WordPress?
You can run it on the same VPS as WordPress if the host allows long-running Node processes. Shared hosting usually does not, so plan for a small separate instance. Keep it on localhost or a private network and never expose it directly to the internet without a shared secret.
Can I use this with OpenAI instead of Claude?
Yes. The MCP server itself is model-agnostic, so the tools stay the same. You only swap the client loop that calls the model. I use Claude by default because its tool-use behavior is predictable, but the architecture does not lock you in.
How much does a WordPress AI chatbot like this cost to run?
Costs come from two places: model tokens and hosting. A small Node instance is a few dollars a month. Token cost depends on traffic and how much content you stuff into the prompt. Caching tool results and trimming context are the two biggest levers.
Is this safe for a client site with customer data?
It can be, if you scope the WordPress user tightly, log every tool call, and avoid tools that write to the database without an allowlist. I would not ship it without the logging, especially for clients in regulated industries.
Can I skip MCP and just use a WordPress chatbot plugin?
For static FAQ answers, yes, and you should. MCP makes sense when the bot needs live data from your site, or when you want to reuse the same tools across other clients and internal automations later.