This tutorial walks through building a Model Context Protocol (MCP) server that lets Claude AI manage a WordPress site, creating posts, updating SEO, uploading images, from any device, just by talking to it.
This covers not just what to run, but why, because the documentation throws tools and commands at you with zero context, and it's easy to spend hours debugging things that should have been obvious.
What Are We Actually Building?
Before writing any code, it helps to understand what an MCP server is and why all these pieces are needed.
MCP (Model Context Protocol) is an open standard that lets AI models like Claude use external tools. Think of it like giving Claude a set of hands, instead of just answering questions, it can actually do things: read your WordPress posts, update SEO fields, upload images to storage.
Here's how the pieces fit together:
You (talking to Claude AI)
|
v
Claude AI (claude.ai)
|
v
Cloudflare Worker <-- This is what we're building
(your MCP server)
| \
v v
WordPress Cloudflare R2
REST API (image storage)
The Cloudflare Worker is a serverless function that runs in the cloud, it's always on, has no server to manage, and has a generous free tier. It acts as the bridge: Claude sends tool requests to it, and it translates them into WordPress API calls or storage operations.
Why Cloudflare Workers specifically? Claude AI's MCP integration requires your server to be reachable over HTTPS with a real domain. A Worker gives you that instantly, no server setup, no SSL certificates, no deployment pipeline. It just works.
Prerequisites
- Node.js v18+, the JavaScript runtime used locally. Check with
node --version - npm, comes with Node.js, used to install packages
- A WordPress site with REST API enabled (it's enabled by default on any modern WordPress install)
- A Cloudflare account, free tier is fine. Sign up at cloudflare.com
- Basic comfort with TypeScript, you don't need to be an expert, but knowing what types are helps
Step 1: Install Wrangler and Log In
Wrangler is Cloudflare's command-line tool for managing Workers. Think of it like the git of Cloudflare, you use it to create, test, and deploy your Worker from the terminal.
Install it globally so you can run wrangler commands from anywhere:
npm install -g wrangler
Then authenticate it with your Cloudflare account:
wrangler login
This opens a browser window where you authorize Wrangler to access your Cloudflare account. You must do this before any deploy commands will work, without it, Wrangler has no idea which account to deploy to.
Step 2: Create the Project
Create a new folder for your project and initialize it as a Node.js project:
mkdir wordpress-mcp && cd wordpress-mcp
npm init -y
npm init -y creates a package.json file, which is just a manifest that tracks your project name, version, and dependencies. The -y flag skips the interactive prompts and accepts all defaults.
Now install the packages needed. Versions matter here:
npm install @modelcontextprotocol/sdk agents@latest ai zod @cloudflare/workers-oauth-provider
npm install -D @cloudflare/workers-types typescript wrangler
What each package does:
@modelcontextprotocol/sdk, the official MCP SDK. This gives us theMcpServerclass that handles the MCP protocol, so we don't have to implement it ourselves.agents@latest, Cloudflare's agent framework. It providesMcpAgent, which handles the connection between our Worker and the MCP protocol over HTTP. Always use@latest, the version npm defaults to (0.0.74) has broken routing that causes silent 404s on authenticated requests. You need 0.7.5+.ai, the Vercel AI SDK, which the latestagentspackage requires as a peer dependency. You won't use it directly, but the install will fail without it.zod, a schema validation library. We use it to define and validate the inputs our MCP tools accept (e.g., "this tool requires a number called post_id"). It also auto-generates the type definitions Claude needs to understand what each tool expects.@cloudflare/workers-oauth-provider, handles OAuth 2.1 authentication. Claude AI requires full OAuth to connect to remote MCP servers, not a simple API key. This library handles most of the heavy lifting.@cloudflare/workers-types(dev), TypeScript type definitions for Cloudflare-specific APIs like KV, R2, and Durable Objects. Without this, TypeScript won't know whatR2BucketorKVNamespaceare.typescript(dev), the TypeScript compiler. Needed locally to type-check the code.wrangler(dev), also installed as a project dependency so the version is locked. The global install from Step 1 is for running commands; this local copy ensures everyone on the project uses the same version.
The -D flag marks packages as "dev dependencies," they're only needed during development, not at runtime. Wrangler bundles your TypeScript before deploying, so the compiled output doesn't need these packages.
Step 3: Configure TypeScript
TypeScript needs a config file to know how to compile your code. Create tsconfig.json in the project root:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}
A few settings worth understanding:
"target": "ES2022", compile to modern JavaScript. Cloudflare Workers run V8, which supports ES2022 natively."moduleResolution": "bundler", tells TypeScript that Wrangler (the bundler) will handle resolving imports, not Node.js's own resolution logic."types": ["@cloudflare/workers-types"], automatically include Cloudflare's type definitions in every file, so you get autocomplete for Worker-specific APIs."noEmit": true, TypeScript won't output compiled files. Wrangler handles the actual compilation; TypeScript is used only for type-checking."strict": true, enables strict type checking. More errors caught at compile time means fewer surprises at runtime.
Step 4: Configure Wrangler
wrangler.toml is the configuration file for your Cloudflare Worker, it's like a blueprint that tells Cloudflare what your Worker is, what resources it needs, and how it's structured. Create it in the project root:
name = "my-wordpress-mcp"
main = "src/index.ts"
compatibility_date = "2025-04-01"
compatibility_flags = ["nodejs_compat"]
# Durable Objects - required for persistent MCP connections
[[durable_objects.bindings]]
name = "MCP_OBJECT"
class_name = "WordPressMcp"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["WordPressMcp"]
# KV namespace for OAuth token storage
[[kv_namespaces]]
binding = "OAUTH_KV"
id = "your-kv-namespace-id"
# R2 bucket for image storage
[[r2_buckets]]
binding = "R2"
bucket_name = "your-bucket-name"
[vars]
WP_URL = "https://your-wordpress-site.com"
Let's break down the less obvious parts:
Durable Objects are a Cloudflare primitive that lets a Worker maintain state and hold open connections. MCP uses a persistent connection (SSE or WebSocket) between Claude and your server, a normal Worker can't do that because each request is stateless. Durable Objects solve this by giving us a single, persistent instance that owns the connection.
Gotcha: The binding name must be MCP_OBJECT. The agents package hardcodes this as the default binding name it looks for. Use anything else and SSE/MCP connections silently fail with 401 errors.
Gotcha: Use new_sqlite_classes in migrations, not new_classes. The newer agents package requires SQLite-backed Durable Objects. Getting this wrong gives you 500 errors with no useful message.
KV Namespace is Cloudflare's key-value store, a simple database for small pieces of data. We use it to store OAuth tokens (the credentials that let Claude authenticate with our server). You need to create one first:
- Go to Cloudflare Dashboard, Workers & Pages, KV
- Click "Create namespace," name it anything (e.g. "mcp-oauth-kv")
- Copy the ID and paste it into
wrangler.toml
R2 Bucket is Cloudflare's object storage (like AWS S3). We'll use it to host images uploaded through the MCP server. Create one in Dashboard, R2 and put the bucket name in wrangler.toml.
compatibility_flags = ["nodejs_compat"] enables Node.js APIs inside the Worker runtime. Some dependencies use Node.js built-ins like Buffer, without this flag, they'd fail at runtime.
Step 5: Set Up WordPress Authentication
We'll connect to WordPress using Application Passwords, a built-in WordPress feature that lets you create separate passwords for API access, without exposing your main account password.
- In your WordPress admin, go to Users, Your Profile
- Scroll down to Application Passwords
- Type a name (e.g. "MCP Server") and click Add New Application Password
- Copy the generated password, WordPress won't show it again
Now create a file called .dev.vars in your project root. This file stores secrets for local development, Wrangler automatically injects them as environment variables when you run locally:
WP_USER=your_wordpress_username
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
ACCESS_PASSWORD=mypassword123
What is ACCESS_PASSWORD? This is a password you create, it's what you'll enter when Claude asks you to authorize the MCP connection. Think of it as the door key: Claude knocks on your server's OAuth page, and you prove you own the server by entering this password.
Important: Add .dev.vars to your .gitignore, never commit secrets to version control.
Gotcha: .dev.vars only works locally. For production deployment, you must set each secret separately using:
wrangler secret put WP_USER
wrangler secret put WP_APP_PASSWORD
wrangler secret put ACCESS_PASSWORD
Each command will prompt you to type the value. This stores secrets encrypted in Cloudflare, they're never in your code or config files.
Gotcha: Keep ACCESS_PASSWORD short (under 32 characters). Long passwords can cause issues in the browser OAuth popup form.
Step 6: Build the WordPress API Client
Create a src/ folder, then create src/wp-client.ts. This is a simple wrapper around WordPress's REST API, instead of writing fetch calls with auth headers everywhere, write it once here and reuse it:
import type { Env } from "./index";
export class WpClient {
private _baseUrl: string;
private authHeader: string;
constructor(env: Env) {
// Remove trailing slash to avoid double slashes in URLs
this._baseUrl = env.WP_URL.replace(/\/$/, "");
// WordPress uses HTTP Basic Auth for Application Passwords
// btoa() converts "username:password" to a Base64 string
this.authHeader =
"Basic " + btoa(`${env.WP_USER}:${env.WP_APP_PASSWORD}`);
}
get baseUrl() { return this._baseUrl; }
async request<T = unknown>(
path: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this._baseUrl}/wp-json${path}`;
const res = await fetch(url, {
...options,
headers: {
Authorization: this.authHeader,
"Content-Type": "application/json",
...((options.headers as Record<string, string>) || {}),
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`WP API ${res.status}: ${body}`);
}
return res.json() as Promise<T>;
}
get<T = unknown>(path: string) {
return this.request<T>(path);
}
post<T = unknown>(path: string, body: unknown) {
return this.request<T>(path, {
method: "POST",
body: JSON.stringify(body),
});
}
delete<T = unknown>(path: string) {
return this.request<T>(path, { method: "DELETE" });
}
}
WordPress's REST API lives at /wp-json/, so a request to /wp/v2/posts becomes https://your-site.com/wp-json/wp/v2/posts. The WpClient handles this prefix automatically.
Step 7: Understand Why We Need OAuth 2.1
This is the part that tends to trip people up most, so it's worth a moment.
When you add a remote MCP server to Claude AI, Claude doesn't just connect to it blindly, it needs to verify that you are authorizing this connection. For that, it uses OAuth 2.1, which is the same standard that powers "Login with Google" buttons across the web.
Here's the flow:
- You add your Worker's URL to Claude's settings
- Claude visits your Worker's
/.well-known/oauth-authorization-serverendpoint to discover how to authenticate - Claude redirects you to your Worker's
/authorizepage (a form we build) - You enter your ACCESS_PASSWORD, this proves you own the server
- Your Worker issues an authorization code, which Claude exchanges for an access token
- Claude uses that token on all future requests
The @cloudflare/workers-oauth-provider library handles most of this automatically. But there's one catch: Claude AI uses https://claude.ai as its OAuth client ID. Most OAuth servers would reject this because it's not a registered client. The library tries to fetch metadata from that URL (to learn what redirect URIs it uses), but that URL serves a webpage, not metadata, so it fails.
The fix is to pre-register Claude AI as a known client directly in KV before the OAuth flow begins. This happens inside the /authorize route handler, shown in Step 9.
Step 8: Create Your MCP Tools
Each "tool" is a function that Claude can call. We define what inputs it accepts (using Zod schemas), what it does, and what it returns. Claude reads these definitions and figures out when to use them based on what you ask.
SEO Tools
Create src/tools/seo.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { WpClient } from "../wp-client";
export function registerSeoTools(server: McpServer, wp: WpClient) {
server.tool(
"get-seo-meta",
"Get SEO meta for a post (title, description, focus keyword)",
{ post_id: z.number().describe("WordPress post/page ID") },
async ({ post_id }) => {
const post = await wp.get<any>(
`/wp/v2/posts/${post_id}?_fields=id,title,slug,meta`
);
const meta = post.meta || {};
return {
content: [{
type: "text" as const,
text: JSON.stringify({
id: post.id,
title: post.title?.rendered,
seo_title: meta.rank_math_title || "",
seo_description: meta.rank_math_description || "",
focus_keyword: meta.rank_math_focus_keyword || "",
robots: meta.rank_math_robots || [],
}, null, 2),
}],
};
}
);
server.tool(
"update-seo-meta",
"Update only SEO fields for a post. Never touches post content.",
{
post_id: z.number(),
seo_title: z.string().optional(),
seo_description: z.string().optional(),
focus_keyword: z.string().optional(),
},
async ({ post_id, seo_title, seo_description, focus_keyword }) => {
const meta: Record<string, unknown> = {};
if (seo_title) meta.rank_math_title = seo_title;
if (seo_description) meta.rank_math_description = seo_description;
if (focus_keyword) meta.rank_math_focus_keyword = focus_keyword;
await wp.post(`/wp/v2/posts/${post_id}`, { meta });
return {
content: [{
type: "text" as const,
text: `SEO meta updated for post ${post_id}`,
}],
};
}
);
}
Notice how each tool has three parts: a name (how Claude identifies it), a description (how Claude decides when to use it, write this as clearly as possible), and a Zod schema (what inputs it requires). The description matters: Claude reads it to understand what the tool does, so be descriptive.
Content Tools
Create src/tools/content.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { WpClient } from "../wp-client";
export function registerContentTools(server: McpServer, wp: WpClient) {
server.tool(
"list-posts",
"List WordPress posts. Use this when asked to find or search for posts.",
{
per_page: z.number().default(10).describe("How many posts to return"),
search: z.string().optional().describe("Search term to filter posts"),
},
async ({ per_page, search }) => {
const params = new URLSearchParams({
per_page: String(per_page),
_fields: "id,title,slug,status,date,link",
});
if (search) params.set("search", search);
const posts = await wp.get(`/wp/v2/posts?${params}`);
return {
content: [{
type: "text" as const,
text: JSON.stringify(posts, null, 2),
}],
};
}
);
server.tool(
"create-post",
"Create a new WordPress post or page",
{
title: z.string().describe("Post title"),
content: z.string().describe("Post content in HTML"),
status: z.enum(["publish", "draft"]).default("draft")
.describe("publish to make it live, draft to save without publishing"),
},
async ({ title, content, status }) => {
const post = await wp.post("/wp/v2/posts", { title, content, status });
return {
content: [{
type: "text" as const,
text: JSON.stringify(post, null, 2),
}],
};
}
);
}
Image Tools with R2
Create src/tools/images.ts. R2 is used instead of WordPress's media library because R2 is faster (served via Cloudflare's CDN) and doesn't eat into your server's storage:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerImageTools(server: McpServer, r2: R2Bucket) {
server.tool(
"upload-image",
"Download an image from a URL and store it in R2 storage",
{
key: z.string().describe("Storage path, e.g. '2025/03/photo.jpg'"),
url: z.string().describe("Public URL to download the image from"),
},
async ({ key, url }) => {
// Fetch the image from the given URL
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch image: ${res.status}`);
const data = await res.arrayBuffer();
const contentType = res.headers.get("content-type") || "image/jpeg";
// Store it in R2 with the correct content type
await r2.put(key, data, {
httpMetadata: { contentType },
});
return {
content: [{
type: "text" as const,
text: JSON.stringify({
key,
size: data.byteLength,
content_type: contentType,
status: "uploaded",
}, null, 2),
}],
};
}
);
server.tool(
"list-images",
"List images stored in R2",
{
prefix: z.string().optional().describe("Filter by path prefix, e.g. '2025/'"),
limit: z.number().default(50),
},
async ({ prefix, limit }) => {
const listed = await r2.list({ prefix: prefix || undefined, limit });
const objects = listed.objects.map((o) => ({
key: o.key,
size: o.size,
uploaded: o.uploaded.toISOString(),
}));
return {
content: [{
type: "text" as const,
text: JSON.stringify(objects, null, 2),
}],
};
}
);
}
Step 9: Wire Everything Together
Create src/index.ts, this is the entry point of your Worker. It handles incoming HTTP requests and routes them to the right place:
import { McpAgent } from "agents/mcp";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import OAuthProvider from "@cloudflare/workers-oauth-provider";
import { WpClient } from "./wp-client";
import { registerSeoTools } from "./tools/seo";
import { registerContentTools } from "./tools/content";
import { registerImageTools } from "./tools/images";
// This interface defines what environment variables/bindings
// are available to our Worker. TypeScript uses this to give
// you autocomplete and catch typos.
export interface Env {
MCP_OBJECT: DurableObjectNamespace;
R2: R2Bucket;
OAUTH_KV: KVNamespace;
WP_URL: string;
WP_USER: string;
WP_APP_PASSWORD: string;
ACCESS_PASSWORD: string;
}
// WordPressMcp is our Durable Object class. It extends McpAgent,
// which handles the persistent MCP connection. We register our
// tools in init() - this runs once when the connection is established.
export class WordPressMcp extends McpAgent<Env> {
server = new McpServer({
name: "wordpress-mcp",
version: "1.0.0",
});
async init() {
const wp = new WpClient(this.env);
registerSeoTools(this.server, wp);
registerContentTools(this.server, wp);
registerImageTools(this.server, this.env.R2);
}
}
// This is the main Worker export. All HTTP requests come here first.
export default new OAuthProvider({
// The URL prefix where our MCP server lives
apiRoute: "/mcp",
// apiHandler handles authenticated MCP requests
// (after OAuth is complete, Claude's requests come here)
apiHandler: {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
return WordPressMcp.serve("/mcp").fetch(request, env, ctx);
},
},
// defaultHandler handles everything else:
// the OAuth flow, the authorize page, etc.
defaultHandler: {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
// The /authorize route shows a login form to the user.
// After they enter ACCESS_PASSWORD, we issue an auth code.
if (url.pathname === "/authorize") {
// Pre-register Claude AI as a known OAuth client.
// Claude sends https://claude.ai as its client_id, which
// the library doesn't recognize by default. We write it
// directly to KV so the library trusts it.
const claudeClientId = "https://claude.ai";
const existing = await env.OAUTH_KV.get(`client:${claudeClientId}`);
if (!existing) {
await env.OAUTH_KV.put(
`client:${claudeClientId}`,
JSON.stringify({
clientId: claudeClientId,
redirectUris: ["https://claude.ai/api/mcp/auth/callback"],
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
tokenEndpointAuthMethod: "none",
registrationDate: Math.floor(Date.now() / 1000),
})
);
}
if (request.method === "POST") {
const form = await request.formData();
const password = form.get("password");
if (password !== env.ACCESS_PASSWORD) {
return new Response(renderForm("Wrong password. Try again."), {
status: 401,
headers: { "Content-Type": "text/html" },
});
}
// Password correct - return a redirect with the auth code
// The OAuthProvider intercepts this and issues a real code
return new Response(null, {
status: 302,
headers: { Location: url.href + "?approved=true" },
});
}
return new Response(renderForm(), {
headers: { "Content-Type": "text/html" },
});
}
return new Response("WordPress MCP Server", { status: 200 });
},
},
// Where to store OAuth tokens
storage: {
get kv() { return (globalThis as any).__oauth_kv; }
},
});
function renderForm(error?: string) {
return `<!DOCTYPE html>
<html>
<head><title>Authorize MCP Access</title></head>
<body style="font-family:sans-serif;max-width:400px;margin:100px auto;padding:20px">
<h2>Authorize WordPress MCP</h2>
<p>Enter your access password to connect Claude AI to this server.</p>
${error ? `<p style="color:red">${error}</p>` : ""}
<form method="POST">
<input type="password" name="password" placeholder="Access password"
style="width:100%;padding:8px;margin-bottom:12px;font-size:16px" autofocus />
<button type="submit"
style="width:100%;padding:10px;background:#1a1a1a;color:white;border:none;font-size:16px;cursor:pointer">
Authorize
</button>
</form>
</body>
</html>`;
}
Gotcha: The URL you give Claude AI is https://your-worker.workers.dev/mcp, NOT the root URL. Claude POSTs directly to whatever URL you provide, so the /mcp path must be there.
Step 10: Deploy to Cloudflare
Before deploying, you need a Cloudflare API token with the right permissions. The default token from wrangler login doesn't have KV write access, which causes the deploy to fail when it tries to set up the KV namespace.
Create a custom token:
- Go to Cloudflare Dashboard, My Profile, API Tokens
- Click "Create Token," "Create Custom Token"
- Add these permissions:
- Account, Workers Scripts, Edit
- Account, Workers R2 Storage, Edit
- Account, Workers KV Storage, Edit
- Create the token and copy it
Now deploy:
export CLOUDFLARE_API_TOKEN=your_token_here
npx wrangler deploy
After deploying, set your production secrets (these are the same values from .dev.vars):
wrangler secret put WP_USER
wrangler secret put WP_APP_PASSWORD
wrangler secret put ACCESS_PASSWORD
Each command prompts you to paste the value. These are stored encrypted in Cloudflare and injected as environment variables at runtime, they never appear in your code or logs.
Step 11: Connect to Claude AI
Now the fun part:
- Go to Claude.ai, Settings, Integrations
- Click Add MCP Server
- Enter:
https://my-wordpress-mcp.yourname.workers.dev/mcp - Claude will open your Worker's
/authorizepage in a popup - Enter your ACCESS_PASSWORD
- Claude is now connected
Try it out:
- "List my last 5 WordPress posts"
- "Update the SEO description for post 42 to..."
- "Create a draft post titled 'Hello World' with this content..."
- "Upload this image URL to R2 as 2025/03/cover.jpg"
Project Structure
wordpress-mcp/
src/
index.ts # Worker entry point, OAuth setup, routing
wp-client.ts # WordPress REST API wrapper
tools/
seo.ts # get-seo-meta, update-seo-meta tools
content.ts # list-posts, create-post tools
images.ts # upload-image, list-images tools
.dev.vars # Local secrets (never commit this)
.gitignore
package.json
tsconfig.json
wrangler.toml # Worker config: bindings, KV, R2, DO
Troubleshooting: Every Gotcha Worth Knowing
A consolidated list of the issues that tend to come up:
- Wrangler not authenticated, always run
wrangler loginbefore any deploy. Without it, deploy commands fail immediately. .dev.varsdoesn't work in production, that file is only for local dev. Usewrangler secret putfor each variable in production.- Wrong Durable Objects syntax, use
[[durable_objects.bindings]]withnew_sqlite_classesin migrations. Not inline arrays, notnew_classes. - Binding name must be MCP_OBJECT, the
agentspackage hardcodes this name. Any other name causes silent 401 failures. - Claude AI requires full OAuth 2.1, not a Bearer token, not an API key. Full OAuth with discovery, authorize, and token endpoints.
- Claude's client_id is a URL, it sends
https://claude.aiasclient_idand skips dynamic registration. Pre-register it in KV manually. - KV namespace needed before deploy, create it in the dashboard first, then add the ID to
wrangler.toml. - API token needs KV permissions, the default
wrangler logintoken doesn't have Workers KV Storage Edit. Create a custom token. agentspackage version, npm defaults to 0.0.74. You need 0.7.5+. Also installaias a peer dep or the build fails.- MCP protocol version, the new SDK only accepts protocol version
2025-03-26. Old versions cause a misleading "Mcp-Session-Id header is required" error. - Wrong URL in Claude AI, use
/mcppath, not the root URL. - Long ACCESS_PASSWORD breaks the form, keep it under 32 characters.
What to Build Next
This covers the foundation. Once it's working, you can extend it with:
- Redirect management, read 404 logs from Rank Math and create redirects automatically
- Bulk image migration, move all images from
wp-content/uploadsto R2 - WooCommerce tools, manage products, orders, and inventory through Claude
- Site health monitoring, check plugin updates, cache status, and error logs
The MCP protocol is flexible, any REST API can become a set of AI-powered tools. The hard part isn't writing the tools themselves; it's fighting the OAuth setup and package versions to get the connection working.