Several PageMotor developers hit the same wall: the site is up, the token is minted, and Claude Code still refuses to connect (or connects and then claims it has no access). There is no single bug. This guide covers every verified cause and gives you the exact 60-second diagnostic for each.
/mcp/ endpoint shipped in PageMotor 0.9b, authenticated by Bearer token only. The OAuth browser sign-in arrived in 0.10b. Two consequences if you are still on 0.9.x: no sign-in flow can ever work (mint a token under Manage API Access instead), and a missing or host-stripped token does not produce an error. The connection quietly succeeds at read-only tier (verified on the wire: an anonymous initialize returns a full handshake). 0.10.x answers a token-less request with an explicit 401 challenge instead.
New, and worth doing first: EP Host Check 1.3.0 now runs this guide’s hosting checks automatically — the trailing-slash redirect (section 3) and the six hosting-environment killers in 6a to 6f, including the bridge-plugin hijack (6f) that every other probe misses — from your PageMotor admin panel, with no working Claude connection required. Install it, read the “MCP / Claude connection” rows, then come back here for the fix detail on whichever row is red. The manual diagnostics below remain for when you want the wire truth yourself. In August 2026 this ordering proved itself in the field: a site migration to shared hosting failed exactly as in 6a, every probe from outside looked healthy, and the plugin’s own report request coming back with the host’s 403 page was the diagnosis in one command.
One curl command tells you which failure family you are in. Replace the site and token, run it, and read the first line of the output.
curl -sS -D - -X POST https://your-site.com/mcp/ \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Authorization: Bearer YOUR-TOKEN" \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0"}}}'
| First line says | You are hitting |
|---|---|
| 301 or 302 | The redirect trap. Fix the URL you configured: trailing slash, https not http, www vs apex. See section 3. |
| 401 (with a valid token) | Your host is stripping the Authorization header, or (0.10.x) OAuth tokens are dying to the timezone bug. See section 6. On 0.9.x the same header-strip shows as a working-but-read-only 200 instead of a 401 (see section 4). |
| 200, but empty body | A plugin is shipping invalid UTF-8 in a tool description. See section 5. |
| 500 or an HTML error page | A plugin is fataling inside the request path. Check the error log and see section 5. |
200 with a JSON initialize result | The server side is healthy. Your problem is client-side configuration: see section 2, or section 4 if Claude connects but denies access. |
If you are connecting in a browser rather than Claude Code and sign-in dies on Invalid resource. The resource does not match this server. Received: "", go straight to trap five in section 2. That empty Received means the client never reached a real MCP endpoint, and the curl above will pass happily while it happens.
--transport httpclaude mcp add defaults to stdio transport. Run it without the flag and Claude Code stores your URL as a local command to execute. No HTTP request ever reaches your site, and the error is indistinguishable from a server fault. The known-good form:
claude mcp add mysite --transport http https://your-site.com/mcp/ \
--header "Authorization: Bearer YOUR-TOKEN"
Then run claude mcp list and confirm the entry shows type http. If it says stdio, remove it and re-add with the flag.
MCP servers are project-scoped by default. Add the server while sitting in one directory, open Claude Code in a different directory, and the server is simply absent. Nothing is broken; it is just not visible from there. Add it in the project folder you actually work in, or use a wider scope.
Claude Code does not hot-reload MCP configuration. After any claude mcp add or config edit, close the session and open a fresh one before judging whether the connection works.
This one bites people re-connecting a site. claude.ai and the Claude desktop app keep their connector list keyed by URL, so if a site was ever connected before (for example by an older token method), adding it again is refused with “A server with this URL already exists”. Nothing is wrong with the site. Remove the existing connector (Settings, then Connectors, then the menu next to the entry, Remove), fully close and reopen the tab or app (the old entry can linger in cache until a proper restart), then add it fresh and sign in again. Claude Code is different: it dedupes by name, not URL, so there it is just claude mcp remove then re-add.
The same treatment applies after you fix a server-side fault from this guide: a connector that was added while the server was broken can hold on to its failed state. If the curl diagnostic now passes but the client still shows no tools, remove the connector, restart, and add it again.
Two lifecycle facts to plan around (observed August 2026). claude.ai allows one connector per URL per organisation, and disconnecting does not release the claim — only deleting does — so you cannot stand a second connector up against the same endpoint to prove a new tier or account before dropping the old one; it is a swap in place, with a gap. And deleting the connector in claude.ai is not a revocation: the OAuth grant stays live on the PageMotor side, listed in the site's Connected Apps with its 30-day expiry, an orphaned credential attached to nothing you can see from the client. Revoke on both sides: delete the connector and remove the grant from Connected Apps in the PageMotor admin.
Related, and the reason trap five below exists: removing the connector does not always clear what claude.ai remembers about that domain.
If the Connectors screen shows nothing for the site yet the error persists, two more places to look. First, sites originally connected through Claude Desktop's old JSON config (the 0.9-era mcp-remote bridge) have their entry in claude_desktop_config.json (macOS: ~/Library/Application Support/Claude/, Windows: %APPDATA%\Claude\). The Connectors UI neither shows nor removes it, but it still claims the URL. Delete that entry, fully quit and reopen the app. Second, check for near-miss variants in the list: an old entry using the www. or slash-less form of the same site still counts. And a red herring to avoid: platform.claude.com is the developer API console, so API keys there have nothing to do with connectors and removing one neither helps nor harms this.
This is the nastiest of the set, because the site is perfectly healthy and every instruction has been followed. You paste https://your-site.com/mcp/, save, and the connector tile shows a different URL: usually an older one you tried months ago, often with a capital /MCP or no trailing slash. Sign-in then fails on a PageMotor error page reading Invalid resource. The resource does not match this server. Received: "".
Received. A blank Received means the client sent no resource value at all, and that has exactly two causes: the client never reached a real MCP endpoint (the stored-URL problem this trap describes), or it reached the correct endpoint while carrying a manually entered OAuth Client ID, which makes claude.ai skip the resource entirely (trap six below). Check the connector tile’s URL first; if it is character-perfect, go straight to trap six. If Received holds an actual address that simply differs from expected, you have a genuine host or slug mismatch instead, and section 3 is your fix.
Two separate things combine:
/MCP is therefore not the MCP endpoint. It 302s to /MCP/, then 404s as an ordinary missing page. No 404 carries the WWW-Authenticate challenge, so no protected-resource document is ever fetched, so no resource value is derived. Claude still finds the sign-in server through the origin-level discovery route and proceeds to authorise with an empty resource, which PageMotor correctly rejects. Trailing-slash advice does not help here; the comparison ignores the slash and only case matters.The case-sensitive half is fixed from 0.11b (probe-verified: /MCP now routes and answers). The stored-URL half is claude.ai-side and unaffected by what the server runs.
The workaround that works today: connect on the www hostname, https://www.your-site.com/mcp/. It is a different host, so the stale per-domain record cannot attach to it. PageMotor builds its site URL from the request host, so the protected-resource document and the sign-in check both come back consistently on www, and the connection goes through in seconds. Check the www form answers before you use it: apex-only sites (a bare subdomain such as studio.example.com) have no www variant, and there the route is to get the capitalisation right and, if the tile still shows the wrong address, connect from Claude Desktop or Claude Code instead, both of which store the URL you actually type.
Before anything else, read the connector tile's URL character by character against the error page's expected string, case first. One capital letter accounted for an entire multi-week support thread.
And a bounding data point: this trap needs prior connector history for the domain to exist at all. A first-ever connection on a fresh hostname went through claude.ai in one attempt, correct URL shown at every step, on a clean box in August 2026. If this is the first connector your organisation has ever pointed at the domain and sign-in still fails, the stored-URL mechanism is not your problem; look at trap six and section 3 instead.
claude.ai’s connector settings offer an advanced field for an OAuth Client ID, and its sign-in failure message actively suggests using it (“or add an OAuth Client ID in the connector settings”). Do not, not with PageMotor. With that field filled, claude.ai switches to a flow that omits the resource parameter from the authorisation request entirely, and PageMotor requires it. The result is the same Invalid resource … Received: "" page as trap five, on a connector whose URL is character-perfect and whose endpoint answers every probe in this guide.
The proof is in the browser’s address bar at the moment of failure: the /oauth/authorize URL carries client_id, redirect_uri, code_challenge and state, and no resource= anywhere. Wire-verified on a live 0.10.3 install in August 2026: the identical request with resource added by hand proceeds straight to the sign-in page.
The fix is to stop using the field. Make a fresh connector entry and leave every OAuth field empty, so claude.ai registers itself and includes the resource on its own. And if you only reached for the Client ID because self-registration kept failing, the registration failure is the thing to fix — it is almost always the hosting layer of section 6a, not PageMotor.
The endpoint is https://your-site.com/mcp/, slash included. Here is why that matters so much.
Any HTTP redirect in front of the endpoint converts the client's POST into a body-less GET (standard fetch redirect semantics), so the JSON-RPC handshake never completes. The trap is that the redirect is invisible to a human: paste the URL into a browser and you get a response, so the endpoint looks alive. But the MCP client sends a POST, and the redirect quietly turns it into an empty GET the handshake cannot use. It resolves like a URL without ever behaving like an MCP. Live probe against a 0.9.4 install:
# POST /mcp (initialize, correct headers, valid JSON body)
HTTP/2 302
location: https://the-site.com/mcp/
x-redirect-by: PageMotor
# following the redirect:
{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Empty request body"}}
The handshake never happens. Who is affected:
example.com/something/). 0.10 added a guard, but it checks the URL path before the install slug is stripped, so it never matches and the redirect still fires.claude mcp add line must match your canonical scheme and host exactly.Root-level 0.10.x installs answer the slash-less POST correctly (verified on the wire), but use the slash anyway: it is the documented form and it sidesteps the whole class.
A common report: the connection succeeds, and Claude still insists it cannot touch the site. Then the user pushes it to try, and suddenly it has full access. Nothing was broken. Two things compound:
PM_API__list-actions, PM_API__describe-action and PM_API__call-action. The handshake never states what access tier the caller holds, and the tool descriptions lead with what anonymous callers may do, so the model assumes it is unprivileged.The reliable fix is one sentence to Claude:
Use the PageMotor MCP tools: call PM_API__list-actions, then call-action.
The moment it calls list-actions it sees the full registry its token unlocks and gets on with the job.
list-actions, your token's tier is too low. Discovery is tier-filtered with no trace: a read-only token hides every admin action entirely, and calling one by name returns “Unknown tool” rather than “requires admin”. Check the token's access tier under Manage API Access; full site management needs an admin-tier token, which only administrator-type users can mint.install-plugin. So the tier is doing all the safety work. The recommended pattern for a connector that manages content (field-proven, August 2026): create a second PageMotor user at producer tier and approve the connector as that user — the content actions (save-content, trash-content, restore-content, list-content, upload-image) sit at producer, while plugins, themes, users, settings and updates stay behind admin. One trap in that flow: PageMotor's consent screen adopts whatever session is already live in the browser, with no account switcher and no “not you?” link. Approve while signed in as an administrator and the connector is an administrator, whatever you named it. Log out of PageMotor fully, log in as the producer user, then approve. And note OAuth grants expire after 30 days; do not build an unattended workflow on one without planning the re-approval. The pattern was proven on 0.10.3b; from 0.11, Producers become explicitly permission-gated per user (existing Producers receive the recommended set on upgrade), so the per-Producer grants in Manage Users become the place to prune exactly what the connector may do — the pattern gets stronger, not weaker.{"class":"PM_API","action":"welcome"} to /api/ with your token; if data.you.token is null, the token never arrived.Disabling all plugins is a genuinely good test, and here is the mechanism behind it. Every active plugin's code is loaded and initialised inside the MCP request path, before the JSON-RPC dispatch runs, and the response is echoed with no output buffering around it. Two consequences:
The cruel part: the 401 challenge and the OAuth discovery routes complete before the plugin init and dispatch step where those fatals and stray output happen, so they stay healthy. You can sign in successfully and then watch the connection fail, which reads exactly like a client bug. It is not.
If you upgraded to PageMotor 0.10 with older plugins still active, start at user-content/logs/pagemotor-errors.log: a number of pre-0.10 plugins are known to fatal on 0.10 and need updates.
tools/list into HTTP 200 with a zero-length body, right after a successful handshake. Disabling plugins “fixes” this one too. If the diagnostic in section 1 gives you 200-with-empty-body, hunt for the plugin whose descriptions carry non-UTF-8 characters.On Apache running PHP as CGI/FastCGI (the default on most shared hosting), the Authorization header is not passed to PHP unless the configuration says so, and PageMotor's generated .htaccess does not yet include that rule. Result on 0.10.x: a perpetual 401 with a perfectly valid token, and disabling plugins changes nothing because the header dies in the web server before PageMotor runs. On 0.9.x, which accepts anonymous connections, the symptom is quieter: the connection works but silently drops to read-only tier, and you only notice when admin actions are missing (see section 4).
Test it: POST to /api/ with your token and the body {"class":"PM_API","action":"welcome"}. If data.you.token comes back null, the host is eating the header. Fix it by adding this inside the PageMotor block of your .htaccess:
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
# belt and braces — some FastCGI setups need the rewrite form as well
RewriteCond %{HTTP:Authorization} .+
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
WordPress ships an equivalent line for exactly this reason.
Placement matters: the line goes in the webroot .htaccess (the one carrying the PageMotor rewrite block), after RewriteEngine On. If it changes nothing, try the other primitive, CGIPassAuth On (Apache 2.4.13+), which works on some FastCGI setups where the SetEnvIf variant does not. And if the token still never arrives after both, the stripping is happening in the host's front-end proxy, above your .htaccess entirely. That needs a support ticket; wording that works: “Please ensure the Authorization HTTP header is passed through to PHP for my domain; it is currently being stripped before reaching my application.”
Two caveats. First, the test only means something with a real, valid token, and the reason is endpoint-specific (field-verified on 0.10.3, August 2026): /mcp/ fails loudly — absent and invalid tokens both draw the JSON-RPC 401 with the WWW-Authenticate pointer — but the REST surface at /api/ silently degrades a garbage credential to an anonymous read-only session, success: true and all. So a made-up value cannot distinguish “stripped” from “wrong token”, and anyone driving /api/ directly can hold a working-looking read-only connection on a credential that is nonsense, noticing only when the write actions are missing. Second, a successful OAuth sign-in proves nothing here: the browser flow runs on redirects that never use the Authorization header. The connection then shows as established on both sides while every actual tool call dies at the web server. If your Connected Apps table shows an active token but the client reports no tools, this is the first thing to check.
The same hosting layer can also block whole requests, not just strip the header. On hosts running ModSecurity or similar filtering, a POST to /api/ or /oauth/register comes back with the host’s own “You don’t have permission to access this resource” page — plain Apache HTML, not PageMotor JSON. In the browser flow this surfaces as “Couldn’t register with [your site]’s sign-in service” before sign-in even begins. Treacherously, a curl from your own machine can succeed while claude.ai’s requests are refused, because the filtering is pattern- and client-sensitive; do not let one passing probe clear the host. The cure is at panel level: switch ModSecurity off for the domain (or ask the host), alongside the header fixes above. Field case, August 2026: one shared host needed the .htaccess lines plus a .user.ini, after which every check in EP Host Check passed and the host ran PageMotor fully.
If you connect by browser sign-in (OAuth) rather than a pasted token, and your server's PHP timezone is ahead of UTC (Europe/Berlin, Europe/Paris and most European shared hosts), the sign-in always fails with an “Unknown or used code” style error. PageMotor writes token expiry times in UTC but reads them back in server-local time, so on a UTC+ host the 60-second authorisation code is judged expired at the moment it is issued. Servers at or behind UTC are immune, which is why the same PageMotor build works for one person and not another.
Workarounds today: set date.timezone = UTC in the site's php.ini or .user.ini, or skip sign-in and use a minted Bearer token (personal tokens carry no expiry and are immune). Fixed in core in 0.11b per its changelog; on 0.10.x the workaround stands, and a UTC timezone remains the right default either way.
CloudPanel ships this cause armed, everywhere. A clean CloudPanel image sets date.timezone = Europe/Berlin in php.ini for every PHP version it carries, whatever region the box is in — verified on a fresh Hostinger KVM in Manchester, August 2026. Fix it before creating any site, because CloudPanel bakes the value into each site's PHP_VALUE at creation time: edit /etc/php/<version>/fpm/php.ini, then restart that php-fpm. A site created before the fix is still recoverable per site, under Settings → PHP Settings → Additional Configuration Directives, where the baked date.timezone is an editable field. One testing trap: the bare php CLI on that image may be a different version than your site runs, so verify with the versioned binary (php8.3 -r 'echo date_default_timezone_get();') or a real request, not with php -r.
PageMotor detects HTTPS only from the local server environment. Behind Cloudflare Flexible SSL the origin never sees HTTPS, so every OAuth URL the site advertises is built as http:// while your client connected over https://, and the flow dies on the mismatch. Test: fetch https://your-site.com/.well-known/oauth-protected-resource and look at the resource field. If it starts http://, switch Cloudflare to Full (strict) with an origin certificate, or pass HTTPS through to PHP (fastcgi_param HTTPS on;). 0.11b adds X-Forwarded-Proto support per its changelog, so this section applies to 0.10.x.
.well-known block swallows OAuth discovery (0.10.x only)PageMotor serves the OAuth discovery documents as dynamic routes at /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server. A stock location ~ /.well-known block (CloudPanel, Plesk and many hand-rolled vhosts ship one for Let's Encrypt) serves that whole path from the filesystem instead, so the client follows PageMotor's 401 pointer into a document PageMotor never wrote, and gives up. A PageMotor developer confirmed exactly this on CloudPanel.
Test it with curl -sI https://your-site.com/.well-known/oauth-protected-resource and read the content-type, not the status code. Exactly one answer is healthy:
# Healthy: PageMotor answered. No ETag, no Last-Modified.
HTTP/2 200
content-type: application/json
# Broken, variant 1: filesystem block, no file there.
HTTP/2 404
# Broken, variant 2: filesystem block, and a real file IS there.
HTTP/2 200
content-type: application/octet-stream
last-modified: Fri, 03 Jul 2026 20:15:09 GMT
etag: "6a48184d-7b"
accept-ranges: bytes
.well-known/oauth-protected-resource or oauth-authorization-server as files on disk, nginx finds them, has no MIME type for an extensionless filename, and falls back to default_type, which is application/octet-stream. Every MCP client rejects that content-type, so the handshake dies in the same place a 404 would have killed it, while curl -sI reports a reassuring 200. The giveaway is the etag, last-modified and accept-ranges trio: those come from nginx serving a file off disk, and never appear on a response PHP generated. Delete the hand-made files, apply the config below, and re-test until the content-type reads application/json. Confirmed in the wild on nginx, 2026-07-10.
The fix keeps the Let's Encrypt challenge served from disk while routing the rest of /.well-known/ to PageMotor. Order does not matter here because ^~ is a prefix match that outranks the ~ regex block, and the longer prefix (acme-challenge) always claims its own requests:
# Keep Let's Encrypt HTTP-01 renewal working (served from disk)
location ^~ /.well-known/acme-challenge/ {
auth_basic off;
allow all;
try_files $uri =404;
}
# Hand the OAuth discovery docs (and the rest of /.well-known/) to PageMotor
location ^~ /.well-known/ {
auth_basic off;
allow all;
try_files $uri $uri/ /index.php?$args;
}
That block is right when the PHP handler lives in the same server block, which is the hand-rolled nginx and Vultr-guide case. Current CloudPanel is not that case. Its rendered vhost is two server blocks: a front one on 80/443 that terminates TLS and proxies everything to a PHP backend on 127.0.0.1:8080, and the backend itself, which holds the root, the try_files and the FastCGI handler. The stock location ~ /.well-known block sits in the front server, where there is no PHP to hand anything to, so the try_files … /index.php fix routes nowhere there. On CloudPanel, proxy the path to the backend instead (field-verified on a clean box, August 2026: 404 text/html before, PageMotor-generated application/json after, acme-challenge still served from disk):
# CloudPanel (two-server vhost): front server blocks
location ^~ /.well-known/acme-challenge/ {
auth_basic off;
allow all;
try_files $uri =404;
}
location ^~ /.well-known/ {
auth_basic off;
allow all;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Two verified notes on that block (clean box, August 2026; finding credit: André). First, it proxies the whole /.well-known/ namespace deliberately rather than enumerating the discovery paths: PageMotor already publishes a third document at /.well-known/api-catalog (RFC 9727), so an enumerated fix is stale the day a fourth appears. Proxy the namespace, not the members. Second, on this two-server stack the backend's try_files also catches the slash-less POST /mcp directly — a Bearer-authenticated JSON-RPC initialize returned 200 with no 301 — so the section 3 redirect does not arise on current CloudPanel and no slash-tolerant /mcp block is needed there.
PageMotor's own docs cover this case too (PM_Docs, doc mcp-connection, section reverse-proxy-deployment-nginx-cloudpanel-plesk, fetchable unauthenticated via /api/). Its fix B enumerates the three discovery paths individually, which the api-catalog point above argues against. Its fix A — narrow the ACME location to ^~ /.well-known/acme-challenge/ and let everything else fall through to the front's location / proxy — may make this a one-block fix on CloudPanel, but is untested on the current two-server shape as of August 2026.
Keep auth_basic off; allow all; on the acme-challenge block. The stock template carries both so Let's Encrypt can renew on sites gated by Basic Auth or IP restrictions; drop them and renewal on a gated site breaks silently, about 90 days later. For any other reverse-proxied stack (Varnish, a Node or app backend), the same shape applies: proxy_pass to whatever your existing location / already targets, do not assume a port, and add proxy_set_header X-Forwarded-Proto https; if the backend does not already assert HTTPS the way CloudPanel's does.
/.well-known/ to the app without the acme-challenge line in front sends Let's Encrypt's HTTP-01 challenge to PageMotor, which cannot answer it, and your certificate then silently stops renewing until it expires around 90 days later. Keep the acme-challenge block, and on CloudPanel make the change through the Vhost Editor so a renewal cannot revert it. After applying, confirm both things: the discovery URL now returns JSON, and your next certificate renewal succeeds.Put a whole PageMotor site behind nginx HTTP Basic Auth, the usual way to keep a preview or client-review build private, and the MCP connection breaks in a sequence that looks like three different bugs but is one theme: the gate, and then the exceptions you cut in it, sit in the path of the machine-to-machine endpoints. You fix one symptom and meet the next. It is not the server coming apart.
The cascade, in the order you meet it:
auth_basic covering everything, every MCP call to /mcp/ gets a 401 challenge the client cannot answer, and any existing connection is invalidated. Same 401 as section 6a, self-inflicted at the web server./mcp/ alone and the browser OAuth flow still fails before it starts, because the client fetches /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource to discover the authorize and token endpoints, and those are still walled off. Same mechanism as section 6d, triggered by basic auth rather than a filesystem block. Exempt /.well-known/ and /oauth/ and sign-in completes (register 201, authorize 200 then 302, token 200 in the access log)./mcp with no trailing slash. An exception block written as location ^~ /mcp/ (slash in the pattern) does not match /mcp, so the request falls through to try_files and nginx issues its own automatic trailing-slash 301. Per section 3, a redirect turns the POST into a body-less GET, the JSON-RPC initialize is lost, and PageMotor answers the GET with its short orientation blurb instead of a handshake. The gate has reintroduced the section 3 redirect on a root 0.10.x install that handles the slash-less POST perfectly well without it.Who is affected: anyone gating a PageMotor site with nginx auth_basic (preview, staging, client-review boxes) and driving it over MCP, from Claude Code or a claude.ai / Cowork connector. The slash-less POST is what claude.ai and Cowork send, so stage three bites connector users specifically.
The diagnostic. The access log is definitive. A broken stage-three handshake shows a POST /mcp 301 followed by a GET /mcp/ 200. A working one, after the fix, shows the POST reaching PageMotor directly:
160.79.106.36 - - [05/Jul/2026:14:49:13 +0000] "POST /mcp HTTP/1.1" 200 3285 "-" "Claude-User"
Tell nginx from PageMotor by the fingerprint: nginx's redirect is a 301 carrying its own ~178-byte HTML page; PageMotor's own reserved-slug redirect (section 3) is a 302 with an empty body. Reproduce without a browser:
curl -sS -D - -o /dev/null -X POST https://your-site.com/mcp \
-H "Authorization: Bearer anything" -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
# A 301 here is the bug. You want PageMotor's JSON-RPC 401 (for a fake token) instead.
The fix. Match both /mcp and /mcp/ with a regex location and route straight to index.php, so nginx never emits the slash redirect. Do the same for the discovery and oauth paths, and leave the site root gated:
location / {
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/.htpasswd-yoursite;
try_files $uri $uri/ /index.php?$args;
}
location ^~ /.well-known/acme-challenge/ {
auth_basic off;
allow all;
try_files $uri =404;
}
location ~ ^/mcp(/|$) {
auth_basic off;
try_files $uri /index.php?$args;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
}
location ^~ /.well-known/ { auth_basic off; try_files $uri $uri/ /index.php?$args; }
location ^~ /oauth/ { auth_basic off; try_files $uri $uri/ /index.php?$args; }
Mind the nginx precedence: an exact ^~ prefix outranks a regex location, and regex locations are tried in file order, so keep the acme-challenge prefix block above and make sure no earlier regex block also matches /mcp. After reloading, the same POST that gave a 301 must now reach PageMotor as a real POST (a JSON-RPC 401 for a fake token, a full initialize for a real one), while GET / still returns 401 Basic.
Authorization header (section 6a). On this case it was tested with a throwaway probe behind the exact /mcp/ routing and disproven: nginx was already forwarding HTTP_AUTHORIZATION to PHP with and without an explicit fastcgi_param, and the token always reached PageMotor. The 401 that looked like a header strip was PageMotor returning its identical “authentication required” for an invalid token as for a missing one. Keep the fastcgi_param line as harmless insurance, but the fix that matters is the slash-tolerant location.
Two traps worth stating. The ^~ acme-challenge block must stay above so certificate renewal keeps serving from disk (do not proxy the whole /.well-known/, see section 6d). And a connector added while the site was broken can hold its failed state: remove it, restart the client, add it fresh (section 2, trap four).
A narrower cousin on shared hosting: directory protection on /admin/ alone. Hosting panels offer per-folder password protection, and putting it on /admin/ looks harmless. But PageMotor’s OAuth sign-in runs through /admin, so the connect window meets the host’s grey password box before PageMotor’s own login ever renders — two doors in a row. Nothing is broken; enter the directory password, then sign in to PageMotor. Just know both doors are yours, or the first box reads as yet another connection failure.
Found in the wild on a PageMotor 0.10.3 site, and it inverts every instinct the rest of this section builds: the parts that usually break are all healthy, and it is the “proven” part that fails. Browser sign-in completes. The Connected Apps table grows a fresh row on every attempt. Discovery returns JSON, the endpoint answers a slash-less POST directly, and the section 1 diagnostic with a made-up token returns PageMotor’s own healthy-looking 401. Then the client reports “Authorization with the MCP server failed” (claude.ai shows it with an ofid_ reference) the moment it uses the connection it just authorised.
The mechanism is section 5 wearing different clothes. Before PageMotor spoke MCP natively, connecting Claude meant a bridge plugin — the “PageMotor Architect AI: Claude” bridge, or the EP MCP Bridge — which answered the MCP route itself, behind its own API key in an X-API-Key header. Leave one active on a 0.10.x core and the request ordering does the damage: an anonymous or invalid-token request is answered by core’s auth preflight before plugins initialise, so every probe in this guide looks healthy; a request carrying a valid OAuth or API token gets past that preflight, plugins initialise inside the dispatch path, and the bridge — which knows nothing about PageMotor’s native tokens — rejects the request at its own gate.
Test it with a real token (one minted in the admin, or from a previously working connection — a made-up value cannot expose this, which is exactly why it hides):
curl -sS -D - -X POST https://your-site.com/mcp \
-H "Authorization: Bearer YOUR-REAL-TOKEN" -H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
The fingerprint of a hijacked dispatch is exact: HTTP 401 with the body {"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Invalid or missing API key."}}, served with a text/html content type rather than JSON, and a CORS Access-Control-Allow-Headers line offering X-API-Key. PageMotor’s own 401 says “Authentication required. See WWW-Authenticate.” and never mentions an API key. If your valid token gets the API-key message, a bridge answered, not core.
The fix: deactivate the bridge plugin in Admin → Plugins, then reconnect (remove the connector and add it fresh, section 2 trap four). On 0.10.x you do not need it — core speaks MCP natively with browser sign-in. Keep it only if you genuinely still connect through it, and expect native connections to fail while it is active. One caution when hunting for it: 0.10 cores also bundle a harmless core plugin named “PageMotor Architect AI: Claude” (the AI provider behind the admin prompt box, no MCP code in it); the one to deactivate is the copy listed among your installed plugins with an on/off toggle.
This one presents almost identically to 6d and has a completely different cause and fix, so read the headers before you touch a vhost. Found in the wild on SiteGround, August 2026: /mcp/ answered correctly with JSON, while both discovery documents came back as an HTML page. The site owner reported it as a 404, which is what his AI assistant had told him. It was not a 404.
The mechanism is a bot-protection layer sitting in front of PHP. It decides a client is automated and returns a CAPTCHA interstitial instead of passing the request through. You never see it in your own browser, because your address is trusted once you have solved a challenge. Claude fetches those documents from datacentre addresses with no cookie and no way to solve a CAPTCHA, so it is challenged every time and receives HTML where JSON should be. Sign-in cannot start.
Test all three documents PageMotor generates, not just the endpoint, and read the content-type and any vendor header, not the status code:
for p in oauth-protected-resource oauth-authorization-server api-catalog; do
printf '%-32s ' "$p"
curl -s -o /dev/null -w "code=%{http_code} type=%{content_type}\n" \
"https://your-site.com/.well-known/$p"
done
# Healthy: PageMotor answered all three.
code=200 type=application/json
# Challenged: a bot wall answered. Note the 202, and that it is not an error code.
code=202 type=text/html
404, or a 200 with application/octet-stream. A bot wall gives you a 202 Accepted carrying a short HTML body that meta-refreshes to a challenge page, plus a vendor header naming the culprit. On SiteGround that header is sg-captcha: challenge and the redirect target is /.well-known/sgcaptcha/, so their CAPTCHA lives in the same namespace as the documents it is blocking. Fetch the body with curl -s rather than -I if you want to see the redirect target and confirm the vendor. Because 202 is a success code, tooling that only checks for 4xx and 5xx will report the site as healthy.
Two things make this hide well. /mcp/ is often left unchallenged while everything around it is walled, which sends people hunting for a fault in PageMotor that is not there; on the SiteGround case /api/, /oauth/authorize and the site root were all challenged while /mcp/ answered cleanly. And the challenge is keyed to the requesting address, so every check you run from your own browser passes while every check Claude runs fails.
The fix is a support ticket, not a config change. On SiteGround there is no Site Tools switch for Anti-Bot AI and no way to exempt individual paths yourself, and nothing you put in .htaccess helps, because the wall sits in front of Apache. Ask the host to disable the bot protection for the site, or to exempt these paths from it:
/.well-known/oauth-protected-resource
/.well-known/oauth-authorization-server
/.well-known/api-catalog
/api/
/oauth/
Wording that saves a round trip: these are API discovery documents that must be readable by automated clients; requests to them currently receive an HTTP 202 CAPTCHA challenge page instead of the JSON the application serves, which stops an external service from authenticating. Naming the 202 matters, because a support agent who hears “404” will go looking for a missing file and find nothing wrong.
Re-run the loop above when they say it is done. All three must read application/json, with no vendor challenge header. If any still returns text/html, the exemption has not taken.
2xx carrying an HTML body, with a 202 status or a captcha marker, reports as a bot wall, while a 404 or the hand-made-file 200 keeps the 6d diagnosis. The caveat matters more than the check. Those probes run from the server itself, so a host that trusts its own machines answers them with clean JSON and the row goes green while Claude is still challenged from outside. A red row here is reliable; a green one is not proof. If a site reports green and still cannot connect, run the curl loop above from a machine that is not the server, and believe that instead.
Everything below was verified against the PageMotor 0.10.2b source with file and line references, and reported upstream. Re-verified unchanged in the 0.10.3b release. Update, 20 August 2026: PageMotor 0.11b ships fixes for several of these. The slash-less POST and the case-sensitive slug routing are probe-verified fixed on an upgraded 0.11b site (a slash-less POST /mcp draws the JSON-RPC 401 directly with no 301, and /MCP now routes), and the 0.11b changelog also lists the timezone skew, X-Forwarded-Proto detection and the empty-payload encoding as fixed. The write-ups below stand because 0.10.3b remains the supported floor and is what most existing sites run. Summarised here so self-hosters know what is a workaround versus what is fixable in core.
0.9.x redirects the slash-less POST itself; 0.10.x's reserved-slug guard in lib/page.php computes its check before the install slug is stripped, so subdirectory installs still 302. Suggested fix: do the reserved check after slug stripping, and never redirect a programmatic endpoint with anything but 308 (which preserves method and body).
Plugin includes and _init() run before JSON-RPC dispatch, and respond() echoes without output buffering. Suggested fix: wrap plugin/theme init in try/catch for MCP and API requests, returning a JSON-RPC internal error, plus output buffering from preflight discarded at respond time.
Expiries are written with gmdate() but parsed with strtotime() in server-local time, in six places across the OAuth and user code. On UTC+ hosts, codes and tokens are expired at issuance. Suggested fix: parse stored expiries as UTC or store epoch integers.
The generated .htaccess has no Authorization forwarding rule and the token reader has no REDIRECT_HTTP_AUTHORIZATION fallback. Suggested fix: ship the rule in the generated block and add the fallback.
SSL detection reads only $_SERVER['HTTPS']. Suggested fix: honour X-Forwarded-Proto behind a trusted-proxy setting.
One invalid UTF-8 byte anywhere in a response payload produces HTTP 200 with an empty body. Suggested fix: encode with JSON_INVALID_UTF8_SUBSTITUTE and emit a JSON-RPC internal error if encoding still fails.
The slug comparison in lib/page.php is case-sensitive, so /MCP is routed as an ordinary theme page and 404s. Because a 404 carries no WWW-Authenticate challenge, the client never derives a resource value and sign-in fails downstream with an empty Received, which reads as a server fault rather than a mistyped URL. The reserved-path guard misses on case too, so the request is still redirected to /MCP/ first. Suggested fix: match the MCP slug case-insensitively in both the routing check and the reserved-path guard, which removes this failure mode entirely.
Nothing in the handshake states the caller's resolved tier, tier-filtered discovery hides higher-tier actions without trace, and calling one returns “Unknown tool”. Suggested fix: state the resolved tier in the initialize instructions, expose a whoami tool, and return “requires admin tier” where that is the truth.
These theories came up during the investigation and did not survive verification. None of them cause the symptoms above, so save yourself the detour:
Related guides: Ask your studio with Claude (connecting claude.ai to a PageMotor site), Claude Code + Cloudflare, and The AI said it was done (why verification beats confidence).