I’ve been wanting to close a particular loop for a while now: I draft articles in Notion, revise them there, and then — when it’s time to publish — copy and paste everything into WordPress by hand, fix the formatting, and wonder why I’m still doing this part myself. Claude can read my Notion. Claude can write. What Claude couldn’t do, until this weekend, was talk to my self-hosted WordPress sites.
The official WordPress connector for Claude only covers WordPress.com hosted sites. For those of us who run our own installations — and I run several — that’s a dead end. The answer turned out to be a plugin called the Atlas AI Connector, which turns a self-hosted WordPress site into a proper MCP server: OAuth 2.1, dynamic client registration, PKCE, the works. This is the story of setting it up, the rabbit hole I fell into along the way, and the working pipeline that came out the other end.
The moving parts
MCP — the Model Context Protocol — is the plumbing that lets Claude use external tools. A connector is an MCP server; Claude discovers what tools it offers and calls them on your behalf. For WordPress, that means tools like “search posts”, “add post”, “list categories” — thirteen of them in Atlas’s case, split into read-only and write/delete groups.
The setup itself is short:
- Install the Atlas AI Connector plugin on your WordPress site.
- Create a dedicated WordPress user for Claude. Mine is an Editor — more on why that’s the right role below.
- In Claude’s settings, under Connectors, add a custom connector pointing at
https://yoursite.tld/wp-json/awfah_mcp/mcp. - Claude opens your site’s OAuth consent screen; log in as the dedicated user and approve.
That’s the theory. In practice, step 4 is where I lost an evening — and the reason turned out to be nothing I could have fixed on the server.
The rabbit hole: when the UI lies to you
After approving the connection, the Connect button in Claude’s settings simply… reverted. Back to “Connect”, as if nothing had happened. Same behaviour in the Linux desktop app and in the web browser. Every instinct said the OAuth handshake was failing somewhere, so Claude and I did what you do: we interrogated the server.
And here’s where it gets educational, because the debugging itself taught me more than the setup did.
Test 1: poke the endpoint without credentials.
curl -si -X POST https://yoursite.tld/wp-json/awfah_mcp/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
A healthy server rejects this with a 401 and — crucially — a www-authenticate header pointing at /.well-known/oauth-protected-resource. That header is the modern MCP spec’s discovery mechanism (RFC 9728, if you collect these). Getting bounced here is good news: it means the OAuth machinery is switched on.
Test 2: validate the whole OAuth chain with two curls.
curl -s https://yoursite.tld/.well-known/oauth-protected-resource | jq
curl -s https://yoursite.tld/.well-known/oauth-authorization-server | jq
The first tells you where the authorization server lives; the second describes it. What you want to see: an authorization_endpoint, a token_endpoint, and — this one is essential for Claude — a registration_endpoint, because Claude registers itself as an OAuth client dynamically. Atlas returned all three, plus PKCE with S256 and authorization-code grants. Textbook.
Test 3: does the Authorization header even reach PHP?
Some nginx and Apache configurations silently strip the Authorization header before WordPress ever sees it, which makes every token — valid or not — look absent. There’s a lovely trick for detecting this without a valid token: send a request with a garbage bearer token and compare the error to the no-token response.
In my case, no token produced the plugin’s own custom 401 (“Please provide a Bearer token…”), while a garbage token produced WordPress core’s rest_forbidden. A changed error message means a changed code path — the header arrived, token validation ran, and correctly rejected nonsense. The web server was innocent.
So: discovery chain correct, headers arriving, consent screen seen and approved. Everything checked out, and the button still reverted. We went to bed with a plan involving WP_DEBUG_LOG and tailing nginx access logs for the token exchange.
The next morning, on a different machine, the mystery dissolved. The macOS Claude app showed the connector as Connected — with a Disconnect button, all thirteen tools listed, and per-tool permission controls the other clients had never displayed. The connection had been working, quite possibly all along. The Connect button wasn’t reporting a failed connection; it was failing to report a successful one. Stale UI state in two out of three beta clients.
The lesson, and it’s one I’ll be reusing: when a connector “won’t connect”, check the same account from a different client before you start debugging the server. The bug was in the speedometer, not the engine.
Why Editor is the right role
My first instinct when things weren’t working was to promote Claude’s WordPress user to Administrator, on the theory that the plugin might demand it. It changed nothing — and once the connection was confirmed working, I demoted the account back to Editor and every tool still worked. Admin was never needed.
Almost. There are tool groups where the plugin genuinely does gate on manage_options: the raw REST API passthrough, which can call any endpoint on the site, and the site-info tools that inventory your plugins, theme and users. Those gates are sensible — one is a skeleton key, the other is reconnaissance data — and the arrangement that falls out is rather elegant:
- Editor token: the curated tools. Read and write posts, pages, categories, tags, media, custom post types. Everything an editorial workflow needs.
- Administrator token: adds the passthrough and site inventories for the rare jobs that need them.
My policy is now least-privilege with an exception clause: the Claude user lives as an Editor, and for the occasional session that genuinely needs raw REST access, I bump it to admin for the duration and demote it afterwards. A public endpoint holding an admin-scoped token is a bigger blast radius than I want as a standing arrangement.
What the connection can actually do
With everything working, the read side delivered immediately: post searches, single posts with full Gutenberg block markup, and — pleasingly — my custom post types. My games site stores its collection in a custom post type, and the connector’s CPT tools listed the type and searched all 629 entries without complaint.
The write test was the satisfying one. Claude created a post with proper Gutenberg block markup — headings, lists, inline code — as a draft, and three details made me trust the whole arrangement:
- The blocks parsed as real blocks (
block_version: 1), not a blob of HTML dumped into the classic editor. Drafts open cleanly for revision. - The post was attributed to the dedicated Claude user, not to me. Anything Claude writes is filterable, reviewable, and visibly not my byline. WordPress’s revision history shows Claude’s edits as diffs under that user — accountability all the way down.
- Draft status means nothing touches the live site until I click publish. The human stays in the loop by construction, not by promise.
We then closed the loop in the other direction: I edited Claude’s draft in wp-admin, and Claude re-read the post and saw my change — raw block markup included, via the edit context. That’s the door to a proofreading workflow: “this post needs your eagle eye for spelling mistakes and command-line typos” is now a thing I can actually say.
One habit worth adopting from day one: the update tool replaces the entire content field rather than patching it. Claude’s working rule is read-fresh-then-write — never write from a stale copy — because the site owner may well have edited in the meantime.
The gaps (and the fixes)
Not everything is exposed. Two limitations surfaced, both worth knowing before you build a workflow on top:
Custom fields need registering for REST. My games site’s custom post type carries hand-rolled custom fields (a store URL, a release year) that turned out to be invisible to the REST API entirely — no meta object in the JSON. That’s not the plugin’s fault: fields only appear in REST if they’re registered with show_in_rest, either via register_post_meta() or ACF’s “Show in REST API” toggle. If you added custom fields by hand years ago and were amazed they worked at all (guilty), they’ll need a small registration snippet before any API consumer can see them:
register_post_meta( 'games', 'store_url', [
'show_in_rest' => true,
'single' => true,
'type' => 'string',
] );
The curated tools don’t take taxonomy or meta parameters for custom post types. Even with show_in_rest fixed, writing custom fields through the connector requires the admin-gated passthrough. For my workflow that’s fine — it’s what the temporary-admin exception clause is for — but it’s the first item on my Atlas wishlist.
Act two: the second site
Flushed with success, I repeated the whole setup on this very site — same plugin, fresh dedicated user, Editor role from the start this time. And promptly hit a wall that looked like the same problem and wasn’t.
Clicking Connect opened a tab that said “could not connect” before any login dialogue appeared. Note the difference: on the games site, the failure (which wasn’t one) came after a successful consent screen, silently. Here the failure announced itself before login. A failure earlier in the chain is a different bug — and this one was real.
The two-curl test earned its keep immediately:
curl -s https://yoursite.tld/.well-known/oauth-protected-resource | jq
jq: parse error: Invalid numeric literal at line 1, column 10
That parse error is jq telling you it was fed HTML, not JSON — the discovery route didn’t exist as far as WordPress was concerned. The cause: the plugin’s rewrite rules had never been flushed on this site. The fix is gloriously anticlimactic: Settings → Permalinks → Save Changes, touching nothing. One re-save later, both discovery documents returned perfect JSON, structurally identical to the games site.
While diagnosing, the caching stack got a hardening pass it should have had anyway. This site runs WP Super Cache, and its default rejected-URI strings only cover .php URLs — so I added awfah-oauth and wp-json to the list. A cached 404 or, worse, a cached token response on those routes would produce exactly the kind of intermittent OAuth failure that eats evenings. If your server also caches at the proxy layer (Plesk’s nginx cache, in my setup, though it turned out to be disabled for this domain), the same two exclusions belong there — preferably via the built-in cache-exception field rather than hand-rolled location blocks, which can collide with the panel’s generated config.
With the routes live and the caches tamed: Connect, login dialogue, consent, and a web tab proudly declaring “connected”. The settings button, naturally, hadn’t moved — stale UI, act two. But this time I knew better than to trust the speedometer, and a simple app restart not only fixed the button but refreshed the tool index in an already-running conversation. Claude could suddenly search both sites mid-chat, no fresh session required.
Which gives us the remedy ladder for a connector that seems stuck, in ascending order of effort: restart the app → try a new chat → actually debug the server. Start at the bottom. The server is innocent more often than the beta clients would have you believe.
One instructive contrast from the second site: this site’s posts come back from the API with a populated meta object, because Jetpack dutifully registers all its fields with show_in_rest — while my hand-rolled games fields remain invisible. Same mechanism, demonstrated live: fields that were formally introduced to WordPress travel through the API; fields that snuck in the back door years ago do not.
And one small touch I’m rather pleased with: the dedicated WordPress user is nicknamed “Jay and Claude”, so anything published through this pipeline carries an automatic co-byline. Credit where credit is due.
The pipeline
Here’s what all of this was for. The workflow I’ve wanted:
- Draft in Notion. Claude and I write and revise there; Notion is the working copy and the cross-session memory.
- Publish day: Claude sends the finished draft to the target WordPress site via the connector — as a draft, with explicit categories (unassigned posts fall into the site default, ask me how I know).
- I review in wp-admin. The block editor shows exactly what will go live; I make final tweaks or send it back for another pass.
- I click publish. Me, not the machine.
Every step keeps the human decision where it belongs while removing the copy-paste-and-fix-formatting drudgery that added nothing. And the article you’re reading is the proof: it was drafted in Notion, sent to WordPress as a draft by Claude through this exact pipeline, reviewed by me, and published with a click.
The loop, finally, is closed.