mcp-tools
Provides tools for interacting with WordPress sites via the WordPress REST API, enabling capabilities such as searching posts and retrieving content from one or more WordPress sites.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@mcp-toolslist recent posts from apil.au"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
mcp-tools
Cloudflare Workers that speak MCP, in one TypeScript repo.
core/mcp.ts shared plumbing: HTTP MCP endpoint + RPC surface + tool typing
core/web.ts what a worker touching the open web needs: stripHtml, a browser UA
core/egress.ts smart / tunnel / browser in the order you pick, then a plain fetch
core/proxy.ts fetch through an HTTP proxy over a raw socket (CONNECT + TLS)
core/apify-ca.ts the key Apify's Unblocker re-signs HTTPS with, for core/proxy.ts
core/vendor/subtls.mjs TLS 1.3 in JavaScript (jawj/subtls), built by scripts/vendor-subtls.mjs
test/ vitest, run against mocks rather than anyone's live service
workers/mcp-toolkit/ aggregator: its own tools + every worker bound under `services`
workers/mcp-wp/ WordPress REST API -> MCP, read and write
workers/mcp-fetch/ fetch one URL in the format you ask for, cache it, search the cache
workers/mcp-archives/ the Wayback Machine: TimeMap, CDX, Save Page Now
workers/mcp-un-docs/ UNISPAL, the UN Digital Library, ODS symbols, RightDocs
workers/mcp-abc-search/ ABC's Algolia index, transcripts included
workers/mcp-abc-ombudsman/ ABC Ombudsman complaint findings
workers/mcp-data-gov-au/ data.gov.au over CKAN, including raw SQL
workers/mcp-ghost/ Ghost publications, with member sign-in for paid posts
workers/mcp-apify/ what Apify is costing you, by service and by Actor
scripts/mock-wp.mjs fake WordPress for local testing
scripts/mock-ghost.mjs fake Ghost, including the magic-link sign-in
scripts/cf-tunnel-relay.mjs runs on the Mac at the far end of the tunnel
scripts/cf-tunnel-relay-agent.sh
makes the Mac start cf-tunnel-relay at login and keep it running
scripts/mock-proxy.mjs fake Apify Proxy, for the proxy tests, re-signing HTTPS as Apify does
scripts/apify-tls-probe.mjs checks HTTPS through the real Unblocker the way the Worker does it
scripts/apify-trust-anchor.py
rebuilds core/apify-ca.ts from certificates Unblocker presentedA worker is a tools object
import cfg from "../wrangler.json";
import pkg from "../package.json";
import { mcpWorker, tool } from "../../../core/mcp";
export default mcpWorker({
...cfg, // name + services come straight from wrangler.json
version: pkg.version,
tools: {
hello: tool({
description: "Say hi",
input: { type: "object", required: ["name"], properties: { name: { type: "string" } } },
run: ({ name }) => `Hi ${name}`, // `name` is typed from `input`; nothing declared twice
}),
},
});Rules the core enforces so nothing needs a mapping table:
The object key is the tool name. Exposed as
<worker>_<key>: workermcp-wp->wp_search_posts. A worker can setprefix: ""to drop that, as mcp-toolkit does for its own three tools.runmay return a string, any JSON value, or a full MCP result ({ content, isError }).toolsmay be a function of the request context when tools depend on the connector URL.A worker with
servicesin its wrangler.json re-exports those workers' tools and routes calls by prefix. The connector's query string is forwarded, so each worker reads its own params. A bound worker that is down loses its own tools and nothing else; the GET page lists what it could not reach.
Related MCP server: WordPress MCP Python
Connector URLs
URL | You get |
| everything |
| only the |
| two whole toolsets |
| exactly those two |
| tools generated from that site (posts, events, …) |
| one set per site, named |
| the wp worker alone ( |
| the same, with the site in the path |
Tools named create_*, update_* and delete_* change the live site. See Editing a site.
Local development
npm install
npm run check # wrangler types + tsc, every worker
npm test # the suite, against the mock WordPress
npm run format # prettier, 100 columns
node scripts/mock-wp.mjs 8799 & # a fake WordPress
cd workers/mcp-toolkit && npx wrangler dev -c wrangler.json -c ../mcp-wp/wrangler.json
curl 'http://localhost:8787/?wp=http://localhost:8799' # lists the generated toolsTwo overrides in package.json hold the test tooling together. The Vitest pool pins a Miniflare
whose runtime stops at an older compatibility date than the workers ask for, so it is pointed at
the Miniflare that the workers' own Wrangler uses; move that pin when Wrangler moves. sharp is
lifted to a patched release; only local image simulation uses it, never a deployed worker. npm 10
fails to resolve Vitest 4's optional peers from scratch, so regenerate package-lock.json with
npx npm@11 install; npm ci on npm 10 installs the result fine.
worker-configuration.d.ts is generated by wrangler types and committed so any editor
type-checks a fresh clone. Re-run npm run check after touching a wrangler.json.
Deploying (GitHub Actions)
.github/workflows/ci.yml runs everything, from pull request to production:
Pull request: the checks, and each changed worker is compiled. A worker whose
wrangler.jsonchanged is also uploaded to Cloudflare as a version that is not deployed, which catches what only Cloudflare can refuse (a setting the plan does not allow, a binding that does not resolve, a missing required secret) before anything merges. A code-only change uploads nothing, so a PR does not push unmerged versions to the top of a worker's version list. A worker that is not on Cloudflare yet is only compiled, because a worker's first upload has to be a deploy.Push to
main: the checks, then a deploy of each changed worker. The deploys wait for the checks, so a redmainnever ships.Run workflow (Actions tab): the checks, then a deploy of every worker.
"Changed" means the deployed code changed, not the files. The plan job
(core/ship.mjs) builds every worker exactly as wrangler deploy would, and fingerprints each
bundle together with its wrangler.json. Every deploy stores that fingerprint on the new version
as its tag, so the next plan compares against what Cloudflare is actually serving. A core/
change reaches only the workers whose bundle it alters (a change to core/egress.ts ships
mcp-fetch alone), and a README, a test or a comment ships nothing. Workers import only version from their
package.json (import { version } from "../package.json"), never the whole file: a whole-file
import inlines devDependencies into the bundle, so bumping a dev tool would redeploy every worker. Each worker's summary is in
the run's summary page. Reading what is live survives a bad minute at Cloudflare: a 5xx, a 429 or
a dropped connection is tried again after 2, 4 then 8 seconds before the plan fails. Any other
error fails it at once, since guessing "changed" would hide a broken token.
Each changed worker then gets its own job, in parallel. Only check installs the whole
workspace: no worker has npm dependencies of its own, so plan builds with wrangler alone, and
each worker's job restores wrangler from the cache plan filled and ships the bundle plan
built, byte for byte.
mcp-toolkit and mcp-wp set "preview_urls": false. Without it, every upload from a pull
request would get a live URL running unmerged code against production resources.
It needs two repository secrets: CLOUDFLARE_API_TOKEN (Workers Editor on these workers, plus
Connectivity Directory Bind for mcp-fetch's tunnel) and CLOUDFLARE_ACCOUNT_ID. The Worker name in the dashboard must equal name in that folder's
wrangler.json. Bindings live in wrangler.json; secrets stay in the dashboard (keep_vars
keeps plain variables too, so WP_SITES survives a deploy).
Adding a worker
Copy
workers/mcp-wptoworkers/mcp-<name>; setnameinwrangler.jsonandpackage.json.Replace
toolsinsrc/index.ts.Add
{ "binding": "<NAME>", "service": "mcp-<name>" }toworkers/mcp-toolkit/wrangler.json. The binding name is yours; the prefix comes fromservice, somcp-un-docsgivesun_docs_and a worker from another repo keeps its own name —shopgivesshop_.That derivation is a guess, and a bound worker is free to disagree with it: one that sets its own
prefix, or that is an aggregator re-exporting the prefixes of the workers bound to it, answers to names this repo cannot derive. Its tools are listed under their real names either way, and a call no prefix claims falls back to asking each bound worker which names it lists, so nothing is listed-but-uncallable. Add"prefix"— a string, or a list — to skip that lookup, which costs every bound worker a tool list — worth doing once you have confirmed what a worker really answers to, e.g."prefix": ["shop_", "stock_"]. Declare it wrong and the fallback cannot save you: the call goes to the worker you named and stops there.npm run check, commit, push tomain; the deploy workflow picks the new folder up.
Getting past blocks
mcp-fetch downloads through core/egress.ts, which tries the routes you name, in the order
you name them. It moves to the next when a route cannot connect, or when the answer is a block:
status 403, 429, 451 or 503, or a challenge header — Cloudflare's cf-mitigated: challenge, or
AWS WAF's x-amzn-waf-action: challenge (sent as a 202) or captcha (a 405). Only headers are
checked; the body is never read to decide. Any other answer, 404 included, is kept.
After the named routes comes the fallback: the worker's own fetch, from a Cloudflare colo.
It is free, always there and has no name, and its answer stands, whatever it is. Only if it cannot
connect at all does the last blocked answer from a named route come back instead.
smart (the default) — Apify Proxy's
UNBLOCKERgroup, throughcore/proxy.ts. It routes each request to get the page, usually through a proxy, dealing with bot checks and CAPTCHAs itself, and picks the country; none is pinned, since Apify says that weakens it. A Worker'sfetchcannot use an HTTP proxy, so this opens a TCP socket toproxy.apify.com:8000and sendsCONNECT. NeedsAPIFY_PROXY_PASSWORDonmcp-fetch— the password on Apify Console -> Proxy, not an API token — and a paid Apify plan. Billed per successful request. Any refusal (407, Apify's 590–599) moves on. Any error status (400 and up, 404 and 502 included) or failure code from Apify is tried once more, and the second answer stands;skippedsays so. A 407 is not retried, nor is a failure with no status (TLS, a timeout). A retry may be billed as a second request.Unblocker re-signs HTTPS with its own key ("Apify Proxy CA"), which the runtime's TLS will not accept and cannot be told to. So TLS inside the tunnel is done in JavaScript, by a vendored build of subtls (
core/vendor/subtls.mjs), trusting that key alone: certificates are still checked, against Apify instead of the public authorities. Apify does not publish the key, so it was recovered from the signatures on two certificates Unblocker presented, andtest/apify-ca.test.tschecks it against them. subtls speaks TLS 1.3 only, which Unblocker accepts. Its author calls it a proof of concept, not for production use.If every https URL starts failing this route on the certificate, Apify has probably changed its key. To check, and to rebuild
core/apify-ca.ts:read -rs APIFY_PROXY_PASSWORD; export APIFY_PROXY_PASSWORD node scripts/apify-tls-probe.mjs example.com # last line: subtls OK or FAILED node scripts/apify-tls-probe.mjs www.wikipedia.org # save each certificate it prints to a .pem file, then: python3 scripts/apify-trust-anchor.py a.pem b.pem # prints the new certificatetunnel — a home connection in Australia, only while the Mac is on.
env.TUNNELis a VPC Service binding (vpc_servicesinmcp-fetch/wrangler.json) to thewmacservice:localhost:8811on the Mac behind Cloudflare TunnelWMac. Runnode scripts/cf-tunnel-relay.mjsthere;ALLOW_DOMAINS=a.org,b.gov.aulimits it to those domains and their subdomains. Alongside each page the Worker asks the relay's/health, which answers at once. If it fails first (no answer within 2 seconds, or Cloudflare saying the tunnel is down), the page is dropped and the tunnel skipped for a minute; if the page answers first, the check is dropped. Either sign of a live Mac is remembered for 30 seconds, and a page that is merely slow costs only that request (the relay gives up on a site after 30 seconds).browser — the same Mac and relay, with the page loaded in Brave there: it waits out challenge pages that clear themselves and has whatever sign-ins you made with
node cf-tunnel-relay.mjs login <url>. The browser keeps its own profile in~/.cf-tunnel-relay/profile. Off unless the relay hasBROWSER_ALLOW_DOMAINS=a.org,b.gov.au, and refused for every other domain, where the page starts and wherever it is redirected. Every connection the browser makes goes through a proxy inside the relay with the same private-address check as plain mode. Needs playwright-core, whichnpm installat the root of the clone brings, and Node 20 or later. Up to 60 seconds a page.
Setting up the Mac
The tunnel WMac and the VPC Service wmac live in Cloudflare, so a wiped Mac only needs one
LaunchAgent back: the relay, which also runs cloudflared for WMac.
Install Node 18 or later (20 for browser mode) and
brew install cloudflared— the program only, with nocloudflared service install; the relay runs it. Clone this repo.From the clone,
sh scripts/cf-tunnel-relay-agent.sh, withALLOW_DOMAINS=…andBROWSER_ALLOW_DOMAINS=…in front if you want them. The first time, it asks for the tunnel token: in the dashboard, Networking -> Tunnels ->WMac-> Add a replica, copy the install command, and paste only its longeyJ…part. It goes into the login Keychain as the itemcf-tunnel-relay, and never into a file, a plist or a command line.It writes
~/Library/LaunchAgents/local.cf-tunnel-relay.plist, which starts the relay at login and restarts it if it dies, and loads it. The relay reads the token from the Keychain and startscloudflared, restarting that too if it dies. The installer waits until/healthanswers andcloudflaredreports a live connection to Cloudflare (its metrics, on127.0.0.1:8812), and shows the end of/tmp/cf-tunnel-relay.errif either does not happen.sh scripts/cf-tunnel-relay-agent.sh offstops both untilonor the next login;--tokenasks for a new token. After agit pull, restart withlaunchctl kickstart -k gui/$(id -u)/local.cf-tunnel-relay.For browser mode,
npm installat the root of the clone (it brings playwright-core) and Brave in/Applications(orCHROME_PATHset to another Chromium browser's executable); thennode scripts/cf-tunnel-relay.mjs login <url>for any sign-ins.
A cloudflared service installed the usual way (sudo cloudflared service install <token>) would
connect the Mac to WMac a second time, and keeps the token in plain text in its plist; the
installer points it out, and sudo cloudflared service uninstall removes it.
Nothing here keeps the Mac awake. The tunnel goes down when the Mac sleeps and comes back when it
wakes: both programs carry on where they were, and cloudflared reconnects. While it is down,
mcp-fetch finds out from /health within 2 seconds and skips it for a minute.
The order is via: a list, ["tunnel", "smart"], or the same as a string, "tunnel,smart".
The fallback always follows, so ["tunnel"] with the Mac off gets Cloudflare's own fetch. Unset,
it is EGRESS_VIA on mcp-fetch (a plain-text variable, same format), and failing that smart.
An unknown name is an error, not skipped.
fetch_url records which route served each URL, and why earlier ones were skipped, in
docs.meta_json.
Any other worker can use the same routes without holding the tunnel or the password: bind
{ "binding": "FETCH", "service": "mcp-fetch" } and call
await env.FETCH.egress(url, { headers, via }). It returns the site's Response with
x-egress-via (fallback for Cloudflare's own fetch) and x-egress-skipped headers.
Binding a VPC Service needs the Connectivity Directory Bind role on whoever deploys, so the
CLOUDFLARE_API_TOKEN in Actions needs it too, or the mcp-fetch deploy fails.
One public door
Only mcp-toolkit has a workers.dev URL. Every other worker sets "workers_dev": false and is
reachable only through the toolkit's service binding, so Cloudflare Access needs to sit on the
toolkit alone — core resolves the signed-in email there and forwards it over RPC.
A bound worker still uses its own secrets: APIFY_TOKEN lives on mcp-apify, WP_SITES on
mcp-wp, the Ghost sessions on mcp-ghost. The toolkit holds none of them and never sees them.
A service binding bypasses Access, which matters for a worker bound from outside this repo. It invokes the bound worker directly, so a worker with its own Access policy gets no second gate here: whoever is on the toolkit's policy can call its tools. The forwarded email still drives whatever authorisation the bound worker does for itself, but reading is gated by the toolkit's allowlist alone. Keep the two policies in step, or do not bind the worker.
workers_dev: false removes the *.workers.dev URL on deploy. A custom domain added in the
dashboard is not managed here and has to be removed there.
Point several connectors at the one worker to keep the grouping you want:
Connector | URL |
APIL |
|
Research |
|
Media |
|
Editing a site
Reads are open. Writing needs a login, and there are two ways to supply one.
Either: a header on the connector
Claude connectors send only header names Anthropic has approved, and X-Auth-Token is one every
connector can use. Put a login in it as user:application password and mcp-wp uses it.
Nothing is stored on the worker, and the credential stays with whoever configured that
connector — so someone can be given write access without a Cloudflare account or an entry in
anything you maintain. Spaces in the password are fine — WordPress prints them in groups of four
and they are stripped before the request goes out.
One header is one login, so it is used only on a connector for exactly one site
(?wp=apil.au). On a connector for several sites there is no saying whose login it is, and in
generic mode it would go to whatever URL a tool call names, so there it is ignored; use
WP_SITES for those. wp_login_status says whether the header was used, and why not.
A header wins over WP_SITES. It also only exists while a person's connector is making the
request: anything unattended sends no headers, so WP_SITES is what a scheduled write would use.
Or: WP_SITES, set in the dashboard on mcp-wp under Settings -> Variables and Secrets.
1. WP_SITES, a plain variable — who may edit what. Readable and editable, because none of it
is secret. Keyed by Cloudflare Access email, then hostname. user is the WordPress login, pass
is the name of the secret holding that login's password:
{
"you@example.com": { "apil.au": { "user": "claude-mcp", "pass": "APIL_CLAUDE" } },
"paul@example.com": { "apil.au": { "user": "paul-mcp", "pass": "APIL_PAUL" } }
}2. One secret per login, named whatever you put in pass — here APIL_CLAUDE and
APIL_PAUL. The value is a WordPress Application Password (Users -> Profile -> Application
Passwords), not the account password.
Secret names are used exactly as written and never derived from the host or username: sanitising
those into one identifier would let different pairs collide (apil.au + x_y and apil.au-x +
y produce the same key). Naming them yourself also means the variable shows, at a glance, which
WordPress user and which secret each site is using.
There is no shared fallback. A site nobody is listed against is read-only, and so is everything if
Cloudflare Access is off, because then no identity reaches the worker. Access identity does not
cross service bindings, so the worker facing the browser resolves the email once and passes it on
with every RPC. Write tools simply do not appear when you have no login for a site, and every one
of them refuses to run until the caller passes user_confirmed: true.
Prefer a dedicated Editor-role WordPress user: application passwords inherit every capability the account has and cannot be scoped.
A get_* tool returns the post body exactly as WordPress stores it, markup and all, because
reading one is usually the first half of editing it — stripping the HTML and writing it back
would replace the blocks, links and embeds with plain text. Search results still strip, since a
400-character preview of raw HTML is mostly angle brackets.
Sites running The Events Calendar are detected from their REST namespaces and get event, venue
and organiser tools that write through tribe/events/v1. The plain post tools stand aside for
those three types, because writing them through wp/v2 silently drops the dates, venue and cost.
Sites running WooCommerce are detected the same way (wc/v3) and get search_wc_*,
get_wc_*, create_wc_*, update_wc_* and delete_wc_* tools for products, variations,
product categories, tags and reviews, orders, order notes, refunds, customers and coupons, plus
get_wc_endpoint to read any other route (reports, settings, shipping, system status). Their
arguments are read from the site's own wc/v3 index, so a field an extension adds shows up
without a code change here, and a method a route does not offer gets no tool. The wp/v2 write
tools stand aside for products, as they do for events. Generic mode has search_wc, get_wc,
create_wc, update_wc, delete_wc and get_wc_endpoint, each taking a url.
Two differences from wp/v2:
WooCommerce will not serve anything without a login, reads included, so none of these tools appear until a login is configured. Use a Shop Manager or Administrator. Either an Application Password or a WooCommerce REST key pair works, in
WP_SITESorX-Auth-Token(ck_…:cs_…); a key pair unlocks only the WooCommerce tools, not thewp/v2writes.A new product is a draft unless you pass a status, because WooCommerce itself publishes by default. Customers, categories, tags, notes and refunds cannot be trashed, so WooCommerce refuses to delete them unless
forceistrue, which deletes them permanently.
Reading paid Ghost posts
Ghost's Content API never serves gated content: a members-only or paid post comes back with an
empty html whatever key you present. The body only exists in the page Ghost renders for a
signed-in member, so mcp-ghost signs in and reads that.
Ghost members have no passwords, so signing in is a magic link, in two tool calls:
ghost_login— asks the site to email you a sign-in link.ghost_login_complete— you paste that link back; the worker follows it itself and keeps the two cookies Ghost sets in reply.
The link works once. Clicking it first, or a mail scanner following it, spends it on that browser
instead, and ghost_login_complete will say so. Sessions are stored in KV against your Cloudflare
Access email, so each person reads paid posts on their own subscription. ghost_session_status
says who you are signed in as; ghost_sign_out forgets it.
GHOST_SITES holds each site's Content API key, which Ghost itself calls safe to expose, so it
can be a plain variable:
{ "www.lamestream.com.au": { "key": "79b548ddd5142126203cac8f8f" } }An entry may still carry cookie and cookie_sig for a shared session, used when the person has
none of their own. Prefer signing in.
Auth (Cloudflare Access)
Access runs before the Worker, so unauthenticated hits cost nothing. Only mcp-toolkit needs it,
since it is the only worker with a URL:
Zero Trust -> Integrations -> Identity providers -> add One-time PIN (email code, no account needed).
Workers & Pages -> the Worker -> Access -> protect it; policy: Include -> Emails -> your list.
Zero Trust -> Access controls -> Applications -> edit that application -> Advanced settings -> turn on Managed OAuth, allow dynamic client registration.
In Claude.ai, add the connector URL. The browser login is the Access page.
Adding a person = adding their email to the policy.
This server cannot be deployed
Maintenance
Related MCP Connectors
Security-first WordPress MCP server. 129 tools for Claude, ChatGPT, Gemini. Free on wp.org.
A MCP server built for developers enabling Git based project management with project and personal…
WordPress MCP server: publish posts, AI images, SEO and full site management, self-hosted
MCP registry & directory: search, find & install 31k+ MCP servers & tools. Catalog and marketplace.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceA comprehensive MCP server for WordPress automation that enables users to manage content, themes, and site configurations using AI-driven workflows and the WordPress REST API. It provides a wide array of tools for site planning, management, and optimization compatible with tools like Cursor and Claude.89 npm1ISC
- AlicenseCqualityDmaintenanceA lightweight MCP server that connects to WordPress via REST API, enabling content management (posts, pages, categories, etc.) and site configuration through natural language commands.49Apache 2.0
- FlicenseNot gradedqualityDmaintenanceEnables interaction with WordPress sites through the WordPress REST API, dynamically exposing all routes as MCP tools for content management and site configuration.-
- AlicenseNot gradedqualityCmaintenanceMCP server for WordPress content management via REST API, supporting posts, pages, media, comments, and terms through natural language interfaces like Cursor, ChatGPT, Codex, and Claude.16 npmMIT