Six months ago I had three separate integrations giving AI assistants access to a client's WordPress site: a custom GPT action, a Cursor rules file with cURL snippets, and a hacked-together Claude plugin. They drifted out of sync within weeks. Today there is one MCP server, about 200 lines of TypeScript, and every assistant on the team uses it. This is how it works and how to build your own.

What the Model Context Protocol actually is

The Model Context Protocol (MCP) is an open, JSON-RPC based protocol that standardises how an AI application (the host) discovers and uses external capabilities exposed by a server. Think of it as USB-C for LLM integrations: the model side does not care whether the server wraps a database, a SaaS API or a shell script, as long as it speaks MCP.

A server can expose three kinds of things:

  • Tools - functions the model can call, with a JSON Schema describing the arguments. Example: search_posts(query, status).
  • Resources - read-only data addressed by URI that the host can load into context. Example: wp://post/1042.
  • Prompts - reusable prompt templates with arguments that the user can invoke by name. Example: /write-meta-description.
┌─────────────────────┐ JSON-RPC 2.0 ┌──────────────────────┐ │ MCP Host │ ◀────────────────────────▶ │ MCP Server (yours) │ │ Claude / Cursor / │ stdio or Streamable │ tools / resources / │ │ your own agent │ HTTP │ prompts │ └─────────────────────┘ └──────────┬───────────┘ │ REST / SQL ┌──────────▼───────────┐ │ WordPress / Postgres │ │ Stripe / filesystem │ └──────────────────────┘

The protocol handles capability negotiation, schema discovery, pagination and progress notifications. You write the business logic. That division is why it took off: the boring parts are solved once, in the SDK.

Why it replaced bespoke tool integrations

Before MCP, "giving an AI access to X" meant building against one vendor's function-calling format, then rebuilding for the next. The tool-calling loop is roughly the same everywhere, but the packaging was not portable. MCP makes the packaging portable, and it adds two things vendor-specific plugins never had: resources (structured context without a tool call) and a security model where the host, not the server, decides what the model may do.

Building a WordPress MCP server in TypeScript

The example below exposes a WordPress site's posts to any MCP client. It uses the official @modelcontextprotocol/sdk package and Zod for schemas. The same shape works for any REST backend.

mkdir wp-mcp && cd wp-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init --target es2022 --module nodenext --moduleResolution nodenext --outDir dist

The server

// src/server.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const WP_URL  = process.env.WP_URL!;                 // https://example.com
const WP_AUTH = 'Basic ' + Buffer.from(`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`).toString('base64');

async function wp<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${WP_URL}/wp-json/wp/v2${path}`, {
    ...init,
    headers: { 'Content-Type': 'application/json', Authorization: WP_AUTH, ...(init.headers ?? {}) },
  });
  if (!res.ok) throw new Error(`WordPress ${res.status}: ${await res.text()}`);
  return res.json() as Promise<T>;
}

const server = new McpServer({ name: 'wordpress', version: '1.0.0' });

/* ---------- TOOL: search posts (read-only) ---------- */
server.tool(
  'search_posts',
  'Search WordPress posts by keyword. Returns id, title, status, link and excerpt.',
  {
    query:  z.string().min(2).max(100).describe('Search keywords'),
    status: z.enum(['publish', 'draft', 'any']).default('publish'),
    limit:  z.number().int().min(1).max(20).default(5),
  },
  async ({ query, status, limit }) => {
    const posts = await wp<any[]>(`/posts?search=${encodeURIComponent(query)}&status=${status}&per_page=${limit}&_fields=id,title,status,link,excerpt`);
    const rows = posts.map(p => ({
      id: p.id, title: p.title.rendered, status: p.status, link: p.link,
      excerpt: p.excerpt.rendered.replace(/<[^>]+>/g, '').trim().slice(0, 200),
    }));
    return { content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }] };
  }
);

/* ---------- TOOL: update SEO meta (write, guarded) ---------- */
server.tool(
  'update_post_meta_description',
  'Update the Yoast/RankMath meta description of a post. Only works on posts the connected user owns.',
  {
    postId: z.number().int().positive(),
    description: z.string().min(50).max(160).describe('Meta description, 150-160 characters'),
  },
  async ({ postId, description }) => {
    // Ownership check: never trust a model-supplied ID blindly.
    const post = await wp<any>(`/posts/${postId}?_fields=id,author`);
    const me   = await wp<any>('/users/me?_fields=id');
    if (post.author !== me.id) {
      return { isError: true, content: [{ type: 'text', text: `Refused: post ${postId} is not owned by the connected user.` }] };
    }
    await wp(`/posts/${postId}`, { method: 'POST', body: JSON.stringify({ meta: { _yoast_wpseo_metadesc: description } }) });
    return { content: [{ type: 'text', text: `Updated meta description for post ${postId}.` }] };
  }
);

/* ---------- RESOURCE: a single post as Markdown-ish text ---------- */
server.resource(
  'post',
  'wp://post/{id}',
  async (uri) => {
    const id = uri.pathname.replace(/^\//, '');
    const p  = await wp<any>(`/posts/${id}?_fields=id,title,content,link,modified`);
    const text = `# ${p.title.rendered}\n\n${p.content.rendered.replace(/<[^>]+>/g, '')}\n\nSource: ${p.link}\nModified: ${p.modified}`;
    return { contents: [{ uri: uri.href, mimeType: 'text/plain', text }] };
  }
);

/* ---------- PROMPT: reusable SEO task ---------- */
server.prompt(
  'write_meta_description',
  'Draft a meta description for a post',
  { postId: z.string() },
  ({ postId }) => ({
    messages: [{
      role: 'user',
      content: { type: 'text', text: `Read resource wp://post/${postId}, then write a 150-160 character meta description that includes the primary keyword naturally. Return only the description.` },
    }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);

That is the whole server. Run it with WP_URL=https://yoursite.com WP_USER=shawab WP_APP_PASSWORD=xxxx npx tsx src/server.ts and it sits waiting for JSON-RPC messages on stdin.

Connecting a host

For Claude Desktop, add the server to claude_desktop_config.json. Cursor and VS Code use an almost identical block in their own settings.

{
  "mcpServers": {
    "wordpress": {
      "command": "npx",
      "args": ["tsx", "/Users/shawab/wp-mcp/src/server.ts"],
      "env": {
        "WP_URL": "https://client-site.com",
        "WP_USER": "shawab",
        "WP_APP_PASSWORD": "abcd efgh ijkl mnop"
      }
    }
  }
}

Restart the host and ask: "Find my draft posts about caching and suggest better meta descriptions." The model calls search_posts, reads each wp://post/{id} resource, drafts descriptions, and asks for confirmation before calling the write tool. The host shows every call and lets the user approve it, which is the security model working as intended.

Going remote with Streamable HTTP

Stdio is perfect for a developer's laptop. For a team, or for a hosted agent, you want the server on a URL. The SDK's Streamable HTTP transport wraps the same McpServer instance in an HTTP endpoint, so nothing above changes:

// src/http.ts
import express from 'express';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { server } from './server.js';        // export the McpServer instance instead of connecting stdio

const app = express();
app.use(express.json());

app.post('/mcp', async (req, res) => {
  if (req.headers.authorization !== `Bearer ${process.env.MCP_TOKEN}`) return res.status(401).end();
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // stateless
  res.on('close', () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});

app.listen(3333, () => console.log('MCP on http://localhost:3333/mcp'));

Put it behind Nginx with TLS (my Nginx tuning notes apply here too), rate-limit it, and use OAuth rather than a static bearer token once more than one person connects. The spec defines an OAuth 2.1 flow for exactly this; I cover the underlying pieces in the authentication guide.

Tool design rules I learned the hard way

  1. Small tools beat clever tools. search_posts and get_post outperform a single wordpress(action, params) tool, because the model reads schemas, not docs. A schema with an enum of twelve actions is a schema the model misuses.
  2. Describe outcomes, not implementation. "Returns id, title, status, link and excerpt" tells the model what it will get back so it can plan the next call.
  3. Return structured text. JSON in a text block is easier for models to reason over than prose. Keep responses under a few KB; paginate instead of dumping.
  4. Errors are instructions. "Refused: post 42 is not owned by the connected user" lets the model recover. A bare 403 does not.
  5. Separate read and write. Ship read-only first. Add writes behind explicit ownership checks and tell the user in the description that the tool mutates data, because hosts surface that text in the approval prompt.
Prompt injection comes through resources

If a tool returns content written by third parties (comments, form submissions, scraped pages), that content can contain instructions aimed at the model. Label untrusted data clearly in the returned text, never let tool output automatically trigger write tools, and keep write tools behind human approval in the host. This is not theoretical; I have watched a model try to "helpfully" follow instructions embedded in a spam comment.

Testing without a chat window

The MCP Inspector is a local web UI that connects to your server, lists its tools and lets you invoke them with arbitrary arguments. It is the fastest feedback loop I have found:

npx @modelcontextprotocol/inspector npx tsx src/server.ts

For automated tests, the SDK ships an in-memory transport so you can spin up the server and a client in the same Vitest process and assert on tool results with no network at all.

Where this fits in a bigger system

An MCP server is the "hands" of an agent. Pair it with the retrieval pipeline from my RAG guide as a search_docs tool, and you have an assistant that can both read and act on your systems. For the agent loop itself, including budgets and guardrails, see AI Agents with Tool Calling.

I now offer MCP server development as part of my integration services, because for agencies it is the shortest path from "we have data" to "our team's AI tools can use it safely".