# PageMotor + Claude Code MCP: why it won't connect — LLM Prompt

> **How to use this file:** Paste the entire contents into Claude, ChatGPT, Gemini, or any LLM as your opening message. It gives the model the whole guide, so it can walk you through it, ask where you have got to, and help when something does not behave.
>
> *Source: [https://documentation.elmspark.com/guides/pagemotor-mcp-troubleshooting/](https://documentation.elmspark.com/guides/pagemotor-mcp-troubleshooting/)*

---

You are helping me with the following. Use this guide as your reference. Work through it with me, ask what I have already done, and help me troubleshoot. Where a step changes something on a live site, a domain, or a server, tell me what it will do and wait for me to confirm before we go on. If something here contradicts what I am actually seeing, say so rather than guessing.

**What this covers:** Why PageMotor sites fail to connect to Claude Code over MCP: every verified cause, from claude mcp add traps to trailing-slash redirects, stripped Authorization headers, timezone-broken OAuth and plugin interference. With the exact diagnostic for each.

# PageMotor + Claude Code MCP: why it won't connect

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.

## 1 Start here: the 60-second diagnostic

**New, and worth doing first:** [EP Host Check](https://documentation.elmspark.com/plugins/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 `Unknown client`, go straight to trap seven in section 2: the OAuth client option is wrong, and no amount of server-side work will help. If it dies on `Invalid resource. The resource does not match this server. Received: ""`, go to trap five instead. That empty `Received` means the client never reached a real MCP endpoint, and the curl above will pass happily while it happens.

## 2 Client-side traps that look like server faults

▶ The change in two minutes and thirteen seconds, including trap seven. Narrated, with captions.

**Read the transcript instead**

**The change.** Claude’s custom connector screen changed in August 2026. If you connect a PageMotor site to Claude, two things are different, and one of them can stop you getting in.

**Step one.** Adding a connector is now two steps. First you give it a name, and the address of your site’s connector: your site’s web address followed by `/mcp/`, all lower case, keeping the trailing slash. Then Claude reads your site before it shows you anything else.

**Step two.** What comes back is a panel with the answers already filled in. Authentication is set to **Always required**, marked Detected. OAuth client is set to **No client ID, register one automatically**, also marked Detected. For a healthy PageMotor site both are correct, so change nothing and press Add.

**The trap.** The first OAuth option is labelled Recommended. It uses client details hosted by Anthropic, and PageMotor has no code for it, so sign-in stops on a page reading **Unknown client**. The label to trust is Detected, not Recommended. The third option, use your own OAuth client, fails differently, on a page reading **Invalid resource**. Leave both alone.

**After sign-in.** Every tool your site offers is listed, and each can be set to always allow, ask first, or never. A studio connector is read-only, so always allow is safe. If you would rather watch it work for a week, set the group to ask.

### Trap one: the missing `--transport http`

`claude 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.

### Trap two: the folder you ran it in

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.

### Trap three: the stale session

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.

### Trap four: the connector you already had

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 (Customize, 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.

### Trap five: claude.ai keeps the old URL and ignores the one you typed

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: ""`.

Two separate things combine:

- **PageMotor matches its MCP slug case-sensitively.** `/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.
- **claude.ai substitutes a remembered URL per domain.** Entering a new name plus the correct URL produces a connector with your new name and the *old* address. The name taking proves a fresh record was created, so only the URL is being inherited. Deleting the tile and watching it disappear from the list does not clear it. Filed as [anthropics/claude-ai-mcp#721](https://github.com/anthropics/claude-ai-mcp/issues/721); a sibling report about cached verdicts surviving a re-add was closed as not planned, so do not wait for a fix.

**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.

### Trap six: your own OAuth Client ID silently drops the resource

claude.ai lets you supply an OAuth Client ID of your own, and its sign-in failure message actively suggests doing so (“or add an OAuth Client ID in the connector settings”). Since August 2026 this is the third option under the **OAuth client** heading, *“Use your own OAuth client”*; before that it was an unlabelled advanced field. Same field, same outcome. Do not use it, 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.

### Trap seven: the OAuth client option marked “Recommended” is the one PageMotor cannot use

Claude’s **Add custom connector** dialog was rebuilt in August 2026. Where there was once a single obscure Client ID box, there is now an explicit **OAuth client** question with three answers, and *the first one is labelled Recommended*. It is the right recommendation in general and the wrong one for PageMotor, which supports exactly one of the three.

#### OAuth client

**Why it fails, in one line.** PageMotor’s client IDs are stateless: the ID *is* the record, a base64 payload and an HMAC signature joined by a single dot, verified against the site’s own secret. A hosted-metadata client ID is an `https://` URL. It has more than one dot and no signature, so verification rejects it before anything else is even looked at.

**Wire-verified on a live 0.11.2 install, August 2026.** Each option was driven to its authorisation request and the response read back:

| OAuth client option | What reaches `/oauth/authorize` | What you get |
|---|---|---|
| Use Anthropic’s hosted client metadata *(Recommended)* | `client_id` is an `https://` URL | **400 Unknown client.** “The client_id is missing or invalid.” |
| No client ID — register one automatically *(Detected)* | A PageMotor-signed ID, plus `resource` | The sign-in page. This is the working path. |
| Use your own OAuth client | A valid ID, but no `resource` | **400 Invalid resource**, `Received: ""` |

**The tell that separates this from traps five and six** is the heading on the error page. `Unknown client` means the client ID itself was rejected, so you are here: change the OAuth client option. `Invalid resource` with an empty `Received` means the ID was fine and the resource was missing, so you are in trap five (wrong address) or trap six (your own client ID). Read the heading before changing anything.

**The fix is to select the middle option and add the connector again.** Leave the client ID box empty and let your site issue one. Nothing needs configuring on the PageMotor side; dynamic registration has been on by default since 0.10b and is what the site advertises in its own discovery document. Support for hosted client metadata would be a core change, so on every PageMotor release to date the answer is the same.

## 3 The trailing slash and the redirect trap

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:

- **Every PageMotor 0.9.x install**, on the slash-less URL, every time. 0.9.x has no reserved-slug guard and redirects the POST itself.
- **0.10.x subdirectory installs** (PageMotor living at `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.
- **Anyone whose configured URL differs from the canonical one.** http vs https, www vs apex, or a CDN rule adding a slash: each produces the same POST-to-GET conversion. The URL in your `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.

## 4 “Claude says it has no access” (it does)

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:

- **Client side:** Claude Code lazy-loads MCP tool schemas. At session start the model holds tool names, not capabilities, and it can hedge into “I don't have access” until something forces a tool call.
- **Server side, by design:** PageMotor deliberately exposes exactly **three** generic dispatch tools (verified on the wire with a full admin token): `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.

## 5 Why “disable all plugins” works

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:

- **A plugin that fatals** takes down every authenticated MCP call with a 500 or an HTML error page.
- **A plugin that outputs anything** (a PHP notice, debug output, maintenance-mode HTML) prepends garbage to the JSON-RPC body, and the client fails to parse it.

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.

## 6 Eight hosting-environment killers

### a. Apache shared hosting eats your Authorization header

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.

### b. The timezone bug that breaks OAuth sign-in entirely (0.10.x only)

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.

Fixed in core from 0.11b per its changelog. **On 0.11 and later, do not set a timezone in php.ini for this.** 0.11 introduced a site timezone setting (Site Settings → Time Zone) and timezone-aware timestamps: core stores UTC and displays in the zone you pick, so the PHP-level value no longer decides anything here. Forcing UTC at the FPM level now buys nothing and can skew any plugin that stamps rows with plain `date()` while reading others with MySQL `NOW()`. **On 0.10.x** the workaround still stands: 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).

**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. **Verify with a real request, not the CLI.** The command line reads php.ini; php-fpm can be serving something else entirely, and a site-level `PHP_VALUE` in the vhost overrides php.ini without changing it. That mismatch is what hides this for weeks. Drop a one-line file in the web root and fetch it over HTTP:

```
echo "<?php echo date_default_timezone_get();" > tz.php
curl -s https://<your-site>/tz.php   # this is the value that matters
rm tz.php
```

The CLI check (`php8.3 -r 'echo date_default_timezone_get();'`, versioned, because the bare `php` on that image may be a different version than your site runs) tells you only what php.ini says. The two can differ in either direction, and on a live CloudPanel fleet in August 2026 they did: php.ini read `Europe/Berlin` on all five installed versions while every site was running with `date.timezone=UTC` in its vhost `PHP_VALUE`. Read the per-site value under Settings → PHP Settings → Additional Configuration Directives, and trust the HTTP request over both.

### c. Cloudflare Flexible SSL and other TLS-terminating proxies (OAuth on 0.10.x)

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.

### d. The nginx `.well-known` block swallows OAuth discovery

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. This is a web-server configuration fault, not a PageMotor one, so no core release fixes it and no version is immune. Confirmed again in September 2026 on a self-managed nginx VPS running a current release, where `/mcp/`, `/api/` and `/oauth/authorize` all answered correctly and only `/.well-known/` returned nginx’s own 404.

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
```

**First, work out which of two vhost shapes you have, because the fix differs and the wrong one silently does nothing.** Do not search for `proxy_pass`: a panel-rendered vhost often carries a template placeholder there instead of the literal directive, and CloudPanel’s is `{{varnish_proxy_pass}}`, which a search for `proxy_pass` will still match but which tells you nothing about the port. **Count the `server {` blocks instead.** If there is more than one, and one of them listens on a back-end port such as `8080` and holds the `root`, the `try_files` and a `fastcgi_pass`, your PHP lives in a *separate* server: that is the CloudPanel shape, and you want the **second** block below. If instead a single server block terminates 443 *and* carries `root`, `try_files` and `fastcgi_pass` together, PHP is right there: that is the hand-rolled and Vultr-guide shape, and you want the **first** block below.

**Where the new blocks go, in both shapes: the server block that terminates 443 and serves your site**, alongside its other `location` entries. On a CloudPanel vhost that is the middle server block, the one whose `server_name` is your real domain, not the short first block that only issues a `return 301` to redirect www, and not the back-end block on 8080. Position within the block does not matter. You do not need to delete the stock `location ~ /.well-known` block: `^~` outranks a regex location, so the new blocks win while it sits there. If your vhost appears to carry no `.well-known` block at all, look again for a one-line `location ~ /.well-known` holding only `auth_basic off;` and `allow all;`, which is easy to read past; if there genuinely is none, that changes nothing, add these anyway.

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 first 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.

### e. nginx HTTP Basic Auth (gating a preview or staging site) breaks MCP in stages

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:

- **Stage one, the connection dies.** With `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.
- **Stage two, “Couldn’t connect” during sign-in.** Exempt `/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).
- **Stage three, “authorized but returned an error when connecting.”** OAuth now succeeds and the handshake still fails, and this is the trap that hides inside the exception you just wrote. The MCP client POSTs to `/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.

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.

### f. A pre-OAuth bridge plugin hijacks the authenticated dispatch

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.

### g. Host bot protection serves a CAPTCHA to the discovery documents

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
```

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.

### h. The site is not reachable from the public internet at all

Worth ruling out first on any private, internal or staging site, because it is the one cause on this list that no amount of header or vhost work will fix. **Claude connects to your MCP server from Anthropic’s own cloud infrastructure, not from the machine you are sitting at.** A site behind a corporate network, a VPN, or a firewall that only admits known addresses will never connect, however perfectly it answers for you.

**The misconception that costs the most time is about Cowork and Claude Desktop.** Both run on your computer, so it is natural to assume they reach your server the way your browser does. They do not. Remote connectors are configured and brokered through your Claude account, so the request still originates from Anthropic’s servers. A `localhost` address, a LAN address, or a hostname that only resolves inside your network cannot work in any of the clients, Cowork and Desktop included. Local MCP servers in Claude Desktop’s `claude_desktop_config.json` are a separate mechanism that genuinely does use your own network, and that mechanism is not available in Cowork or claude.ai.

The tell is the same shape as the ModSecurity case in 6a: everything passes from your machine and nothing works from Claude. Test from somewhere that is not your network and not the server, a phone on mobile data being the quickest honest check. If the site is genuinely meant to stay private, allowlist Anthropic’s published IP ranges inbound rather than opening the host up.

## 7 For core developers: the confirmed defects

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.

**Redirects on the MCP endpoint (section 3)**

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).

**Plugins inside the MCP request path (section 5)**

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.

**OAuth timezone skew (section 6b)**

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.

**Authorization pass-through (section 6a)**

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.

**Proxy HTTPS detection (section 6c)**

SSL detection reads only `$_SERVER['HTTPS']`. Suggested fix: honour `X-Forwarded-Proto` behind a trusted-proxy setting.

**Unguarded JSON encoding (section 5, empty-body variant)**

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.

**Case-sensitive MCP slug routing (section 2, trap five)**

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.

**Tier visibility (section 4)**

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.

## 8 Checked and ruled out

These theories came up during the investigation and did not survive verification. None of them cause the symptoms above, so save yourself the detour:

- MCP protocol-version negotiation between client and server
- CORS and OPTIONS preflight handling (irrelevant to CLI clients)
- Claude Code's Accept headers and SSE stream handling
- Client-side URL path stripping
- Bun-versus-Node networking differences in the client
- Validation of plugin-contributed tool names and schemas (0.10.2b normalises these adequately)
- Mid-session reconnect behaviour

**Related guides:** [Ask your studio with Claude](https://documentation.elmspark.com/guides/ask-your-studio-with-claude/) (connecting claude.ai to a PageMotor site), [Claude Code + Cloudflare](https://documentation.elmspark.com/guides/claude-code-cloudflare/), and [The AI said it was done](https://documentation.elmspark.com/guides/ai-said-it-was-done/) (why verification beats confidence).

---

*Generated from the guide above. If something here looks wrong, the guide is the source of truth: https://documentation.elmspark.com/guides/pagemotor-mcp-troubleshooting/*
