@respira/wordpress-mcp-server 6.11.1 → 6.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,23 @@ export class WordPressClient {
40
40
  versionWarning = null;
41
41
  // Last HTML-instead-of-JSON observation, surfaced by wordpress_diagnose_connection.
42
42
  lastHtmlInsteadOfJson = null;
43
+ /**
44
+ * Per-session sticky flag: once a `?rest_route=` retry succeeds (because a
45
+ * plugin or theme rewrite rule shadows the pretty `/wp-json/...` path and
46
+ * `redirect_canonical()` 301s the request to the homepage), every subsequent
47
+ * REST call from this client instance goes directly to `?rest_route=` form
48
+ * and skips the pretty-permalink probe. In-memory only, resets on restart.
49
+ * Can be primed via `siteConfig.forceRestRoute = true`.
50
+ */
51
+ useRestRouteFallback = false;
52
+ restRouteFallbackWarned = false;
53
+ /**
54
+ * Diagnostic: whether the most recent `?rest_route=` probe (either the
55
+ * automatic retry triggered by an HTML response or the explicit probe in
56
+ * `diagnoseConnection`) returned valid JSON. Surfaced as
57
+ * `rest_route_fallback_worked` on the diagnose tool result.
58
+ */
59
+ lastRestRouteFallbackWorked = null;
43
60
  constructor(siteConfig) {
44
61
  this.siteConfig = siteConfig;
45
62
  this.defaultHeaders = {
@@ -63,6 +80,32 @@ export class WordPressClient {
63
80
  headers: this.defaultHeaders,
64
81
  timeout: 30000,
65
82
  });
83
+ // Prime the per-session sticky flag from explicit per-site config.
84
+ if (siteConfig.forceRestRoute === true) {
85
+ this.useRestRouteFallback = true;
86
+ }
87
+ // Request interceptor: when the per-session sticky flag is on (either set
88
+ // explicitly via siteConfig.forceRestRoute or auto-tripped by a successful
89
+ // `?rest_route=` retry), rewrite outgoing `/wp-json/...` URLs to the
90
+ // `?rest_route=` form against the site root. Skips requests already in
91
+ // fallback form and requests flagged as the retry attempt itself
92
+ // (`_restRouteFallback: true` on the request config).
93
+ const requestInterceptor = (config) => {
94
+ try {
95
+ if (this.useRestRouteFallback &&
96
+ config &&
97
+ !config._restRouteFallback &&
98
+ this.shouldRewriteToRestRoute(config)) {
99
+ this.rewriteRequestToRestRoute(config);
100
+ }
101
+ }
102
+ catch {
103
+ // Never block a request on a rewrite error — fall through.
104
+ }
105
+ return config;
106
+ };
107
+ this.client.interceptors.request.use(requestInterceptor);
108
+ this.rootClient.interceptors.request.use(requestInterceptor);
66
109
  // Add response interceptor for error handling
67
110
  const errorInterceptor = async (error) => {
68
111
  const handledError = await this.handleError(error);
@@ -71,31 +114,72 @@ export class WordPressClient {
71
114
  // Success interceptor: detect HTML masquerading as JSON on Respira REST routes.
72
115
  // An edge layer (Cloudflare, Wordfence, .htaccess, maintenance page) can return a
73
116
  // 200 OK with an HTML body instead of JSON; if we don't catch it here, JSON.parse
74
- // explodes deep in a tool handler with an opaque message. Throwing a structured
75
- // error here gives diagnose_connection something to fingerprint.
76
- const htmlGuard = (response) => {
77
- this.detectHtmlInsteadOfJson(response);
78
- return response;
117
+ // explodes deep in a tool handler with an opaque message. As of v6.11.2, when
118
+ // the failure mode looks like a WordPress rewrite shadowing the path
119
+ // (`/wp-json/[anything]` homepage 301 from `redirect_canonical()`), we
120
+ // automatically retry the same call as `?rest_route=...` against the site
121
+ // root. If that succeeds, set `useRestRouteFallback` for the rest of the
122
+ // session and return the retried response. Otherwise surface the original
123
+ // error with both URLs in the message.
124
+ const htmlGuard = async (response) => {
125
+ const observation = this.observeHtmlInsteadOfJson(response);
126
+ if (!observation)
127
+ return response;
128
+ // Cache observation for diagnose_connection.
129
+ this.lastHtmlInsteadOfJson = observation;
130
+ // Try the `?rest_route=` fallback only once per request.
131
+ const requestConfig = response.config || {};
132
+ const isAlreadyFallback = requestConfig._restRouteFallback === true;
133
+ const canFallback = !isAlreadyFallback && this.canRewriteToRestRoute(requestConfig);
134
+ if (canFallback) {
135
+ try {
136
+ const retryResp = await this.retryViaRestRoute(requestConfig);
137
+ // If the retry returned HTML too, we drop through to the throw below.
138
+ const retryObservation = this.observeHtmlInsteadOfJson(retryResp);
139
+ if (!retryObservation) {
140
+ // Sticky flag + one-time stderr warning per session per site.
141
+ if (!this.useRestRouteFallback) {
142
+ this.useRestRouteFallback = true;
143
+ if (!this.restRouteFallbackWarned) {
144
+ this.restRouteFallbackWarned = true;
145
+ process.stderr.write(`[respira-mcp] Site ${this.siteConfig.name} has REST rewrite shadowing; ` +
146
+ `falling back to ?rest_route= for this session. ` +
147
+ `Run wordpress_diagnose_connection for triangulation.\n`);
148
+ }
149
+ }
150
+ this.lastRestRouteFallbackWorked = true;
151
+ return retryResp;
152
+ }
153
+ // Retry also returned HTML — record and fall through to error.
154
+ this.lastRestRouteFallbackWorked = false;
155
+ }
156
+ catch {
157
+ this.lastRestRouteFallbackWorked = false;
158
+ }
159
+ }
160
+ throw this.buildHtmlInsteadOfJsonError(observation, requestConfig);
79
161
  };
80
162
  this.client.interceptors.response.use(htmlGuard, errorInterceptor);
81
163
  this.rootClient.interceptors.response.use(htmlGuard, errorInterceptor);
82
164
  }
83
165
  /**
84
- * Detect HTML responses on what should be JSON REST routes. Throws a structured
85
- * Error with code `respira_html_instead_of_json` so callers (and the diagnose
86
- * tool) can fingerprint the edge-layer interception.
166
+ * Inspect a response for the "HTML instead of JSON" failure mode on Respira
167
+ * REST routes. Returns a structured observation when the response looks like
168
+ * HTML (or null when it's a normal JSON response or a non-Respira call we
169
+ * shouldn't guard, e.g. wp-admin/admin-ajax media uploads). Pure detection;
170
+ * the caller decides whether to retry via `?rest_route=` or throw.
87
171
  */
88
- detectHtmlInsteadOfJson(response) {
172
+ observeHtmlInsteadOfJson(response) {
89
173
  if (!response || typeof response !== 'object')
90
- return;
174
+ return null;
91
175
  const requestUrl = response.config?.url || '';
92
176
  const baseUrl = response.config?.baseURL || '';
93
177
  const fullUrl = requestUrl.startsWith('http') ? requestUrl : `${baseUrl}${requestUrl}`;
94
- // Only guard Respira REST routes other endpoints (e.g. media uploads to
95
- // wp-admin/admin-ajax) may legitimately return HTML.
96
- const isRespiraRoute = /\/wp-json\/respira\//i.test(fullUrl);
178
+ // Guard Respira REST traffic in either form: pretty `/wp-json/respira/...`
179
+ // or `?rest_route=/respira/...` against the site root.
180
+ const isRespiraRoute = /\/wp-json\/respira\//i.test(fullUrl) || /[?&]rest_route=\/?respira\//i.test(fullUrl);
97
181
  if (!isRespiraRoute)
98
- return;
182
+ return null;
99
183
  const headers = response.headers || {};
100
184
  const contentType = (headers['content-type'] || headers['Content-Type'] || '').toString().toLowerCase();
101
185
  const body = response.data;
@@ -104,11 +188,17 @@ export class WordPressClient {
104
188
  : Buffer.isBuffer?.(body)
105
189
  ? body.toString('utf8')
106
190
  : null;
191
+ // The HTML detector also matches WordPress-style 301 redirects that returned
192
+ // an empty body with just a `Location:` header (when transformResponse keeps
193
+ // the body raw, axios usually still has data===''). The caller spots those
194
+ // via the `x-redirect-by: wordpress` header signature, but in the success
195
+ // path axios has already followed the redirect, so we end up with the
196
+ // homepage HTML body — caught by the regex below.
107
197
  const looksLikeHtml = contentType.includes('text/html') ||
108
198
  (typeof bodyAsString === 'string' &&
109
199
  /^\s*(<!doctype|<html)/i.test(bodyAsString.slice(0, 64)));
110
200
  if (!looksLikeHtml)
111
- return;
201
+ return null;
112
202
  const status = response.status ?? 0;
113
203
  const snippet = (bodyAsString ?? '').slice(0, 200);
114
204
  // Extract <title> text if present.
@@ -119,8 +209,7 @@ export class WordPressClient {
119
209
  titleText = m[1].trim();
120
210
  }
121
211
  }
122
- // Cache the observation for diagnose_connection.
123
- this.lastHtmlInsteadOfJson = {
212
+ return {
124
213
  url: fullUrl,
125
214
  status,
126
215
  contentType: contentType || 'unknown',
@@ -128,17 +217,193 @@ export class WordPressClient {
128
217
  bodySnippet: snippet,
129
218
  detectedAt: new Date().toISOString(),
130
219
  };
131
- const titleLabel = titleText ? `'${titleText}'` : '(no <title>)';
220
+ }
221
+ /**
222
+ * Build the structured "HTML instead of JSON" Error that gets surfaced when
223
+ * neither the original pretty-permalink request nor the `?rest_route=`
224
+ * fallback returned JSON. Includes both URLs so the user can see the fix
225
+ * was tried.
226
+ */
227
+ buildHtmlInsteadOfJsonError(observation, requestConfig) {
228
+ const titleLabel = observation.titleText ? `'${observation.titleText}'` : '(no <title>)';
229
+ const fallbackUrl = this.computeRestRouteFallbackUrl(requestConfig);
230
+ const fallbackHint = fallbackUrl
231
+ ? ` Auto-fallback to ${fallbackUrl} also returned HTML.`
232
+ : '';
132
233
  const err = new Error(`Got HTML instead of JSON. Page title: ${titleLabel}. ` +
133
- `Likely an edge layer (Cloudflare, Wordfence, .htaccess) is intercepting REST requests. ` +
234
+ `Likely an edge layer (Cloudflare, Wordfence, .htaccess) or a plugin/theme ` +
235
+ `rewrite rule is intercepting REST requests on ${observation.url}.${fallbackHint} ` +
134
236
  `Run wordpress_diagnose_connection for a fingerprint.`);
135
237
  err.code = 'respira_html_instead_of_json';
136
- err.status = status;
137
- err.contentType = contentType;
138
- err.bodySnippet = snippet;
139
- err.titleText = titleText;
140
- err.url = fullUrl;
141
- throw err;
238
+ err.status = observation.status;
239
+ err.contentType = observation.contentType;
240
+ err.bodySnippet = observation.bodySnippet;
241
+ err.titleText = observation.titleText;
242
+ err.url = observation.url;
243
+ if (fallbackUrl)
244
+ err.fallbackUrl = fallbackUrl;
245
+ return err;
246
+ }
247
+ // ---------------------------------------------------------------------------
248
+ // ?rest_route= fallback helpers (v6.11.2)
249
+ // ---------------------------------------------------------------------------
250
+ /**
251
+ * Returns true when this axios request config targets a `/wp-json/...` path
252
+ * that we can rewrite to `?rest_route=...` against the site root.
253
+ */
254
+ canRewriteToRestRoute(config) {
255
+ if (!config)
256
+ return false;
257
+ const requestUrl = config.url || '';
258
+ const baseUrl = config.baseURL || '';
259
+ const fullUrl = requestUrl.startsWith('http') ? requestUrl : `${baseUrl}${requestUrl}`;
260
+ if (!fullUrl)
261
+ return false;
262
+ if (/[?&]rest_route=/i.test(fullUrl))
263
+ return false;
264
+ return /\/wp-json\//i.test(fullUrl);
265
+ }
266
+ /**
267
+ * Same as `canRewriteToRestRoute`, plus a guard against rewriting twice (the
268
+ * request interceptor invokes this; the response interceptor's retry path
269
+ * uses the lower-level `canRewriteToRestRoute`).
270
+ */
271
+ shouldRewriteToRestRoute(config) {
272
+ if (!config)
273
+ return false;
274
+ if (config._restRouteRewritten)
275
+ return false;
276
+ return this.canRewriteToRestRoute(config);
277
+ }
278
+ /**
279
+ * Convert a `/wp-json/<namespace>/<route>` request config into an equivalent
280
+ * `?rest_route=/<namespace>/<route>` request against the site root. Mutates
281
+ * the config in place so axios picks up the rewrite naturally on the
282
+ * outgoing request. Marks the config as rewritten so subsequent interceptor
283
+ * passes don't double-rewrite.
284
+ */
285
+ rewriteRequestToRestRoute(config) {
286
+ const fallbackUrl = this.computeRestRouteFallbackUrl(config);
287
+ if (!fallbackUrl)
288
+ return;
289
+ config.url = fallbackUrl;
290
+ config.baseURL = undefined;
291
+ // Wipe `params` because we've folded everything into the URL string and
292
+ // axios would otherwise re-append them after a `?` it inserts itself.
293
+ config.params = undefined;
294
+ config._restRouteRewritten = true;
295
+ }
296
+ /**
297
+ * Pure helper: compute the `?rest_route=` URL that corresponds to a given
298
+ * axios request config. Returns null when the config doesn't target a
299
+ * `/wp-json/...` path. Preserves all original query params.
300
+ */
301
+ computeRestRouteFallbackUrl(config) {
302
+ if (!config)
303
+ return null;
304
+ const requestUrl = config.url || '';
305
+ const baseUrl = config.baseURL || '';
306
+ const fullUrlStr = requestUrl.startsWith('http') ? requestUrl : `${baseUrl}${requestUrl}`;
307
+ if (!fullUrlStr)
308
+ return null;
309
+ // Already in fallback form? Nothing to do.
310
+ if (/[?&]rest_route=/i.test(fullUrlStr))
311
+ return null;
312
+ let parsed;
313
+ try {
314
+ parsed = new URL(fullUrlStr);
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ // Extract `/<namespace>/<route>` from the pathname after `/wp-json`.
320
+ const pathMatch = parsed.pathname.match(/^(.*?)\/wp-json(\/.*)?$/i);
321
+ if (!pathMatch)
322
+ return null;
323
+ const beforeWpJson = pathMatch[1] || '';
324
+ const afterWpJson = pathMatch[2] || '/';
325
+ // Build the new URL: <origin><beforeWpJson>/?rest_route=<afterWpJson>&<original-params>
326
+ const fallback = new URL(parsed.origin);
327
+ fallback.pathname = `${beforeWpJson}/`.replace(/\/+/g, '/');
328
+ // Build the query string manually so `rest_route` is first and the slashes
329
+ // inside its value stay literal (some restrictive WAF rules see encoded
330
+ // slashes and block, even though WP's REST router accepts both forms).
331
+ const restRouteValue = afterWpJson.startsWith('/') ? afterWpJson : `/${afterWpJson}`;
332
+ const extraParams = [];
333
+ // Original query params from the URL itself.
334
+ parsed.searchParams.forEach((v, k) => {
335
+ if (k.toLowerCase() === 'rest_route')
336
+ return;
337
+ extraParams.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
338
+ });
339
+ // Original axios `params` object (config.params), folded in. axios's default
340
+ // serializer treats `undefined`/`null` values as "skip"; mirror that here.
341
+ const cfgParams = config.params;
342
+ if (cfgParams && typeof cfgParams === 'object') {
343
+ for (const [k, v] of Object.entries(cfgParams)) {
344
+ if (v === undefined || v === null)
345
+ continue;
346
+ if (Array.isArray(v)) {
347
+ v.forEach((item) => {
348
+ if (item === undefined || item === null)
349
+ return;
350
+ extraParams.push(`${encodeURIComponent(k)}[]=${encodeURIComponent(String(item))}`);
351
+ });
352
+ }
353
+ else {
354
+ extraParams.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
355
+ }
356
+ }
357
+ }
358
+ const queryString = `rest_route=${restRouteValue}` + (extraParams.length ? `&${extraParams.join('&')}` : '');
359
+ return `${fallback.origin}${fallback.pathname}?${queryString}`;
360
+ }
361
+ /**
362
+ * Re-issue the original request as a `?rest_route=` call against the site
363
+ * root. Uses a bare `axios.request` (NOT the interceptor-wrapped client) so
364
+ * the request and response interceptors don't fire on the retry — we handle
365
+ * the retry's HTML check inline in the caller. Marks the request with
366
+ * `_restRouteFallback: true` for symmetry / debugging.
367
+ */
368
+ async retryViaRestRoute(originalConfig) {
369
+ const fallbackUrl = this.computeRestRouteFallbackUrl(originalConfig);
370
+ if (!fallbackUrl) {
371
+ throw new Error('rest_route fallback URL could not be computed');
372
+ }
373
+ const headers = { ...(originalConfig.headers || {}) };
374
+ // Strip axios-internal `common`/`get`/`post` per-method header buckets if any.
375
+ delete headers.common;
376
+ delete headers.get;
377
+ delete headers.post;
378
+ delete headers.put;
379
+ delete headers.patch;
380
+ delete headers.delete;
381
+ delete headers.head;
382
+ const retryConfig = {
383
+ method: originalConfig.method || 'get',
384
+ url: fallbackUrl,
385
+ headers,
386
+ data: originalConfig.data,
387
+ timeout: originalConfig.timeout || 30000,
388
+ // Force raw response body so we can sniff content-type / body for the
389
+ // HTML detector regardless of what axios would otherwise do.
390
+ transformResponse: (raw) => {
391
+ if (typeof raw !== 'string')
392
+ return raw;
393
+ // Try to JSON.parse; if that fails, hand back the string so the HTML
394
+ // detector can run on it.
395
+ try {
396
+ return JSON.parse(raw);
397
+ }
398
+ catch {
399
+ return raw;
400
+ }
401
+ },
402
+ validateStatus: (s) => s >= 200 && s < 300,
403
+ maxRedirects: 5,
404
+ _restRouteFallback: true,
405
+ };
406
+ return axios.request(retryConfig);
142
407
  }
143
408
  /**
144
409
  * Set the current MCP tool name so it's sent as X-Respira-Tool-Name on
@@ -1445,6 +1710,14 @@ export class WordPressClient {
1445
1710
  await probe('respira_diagnostic_report', 'GET', `${baseUrl}/wp-json/respira/v1/diagnostic/report`);
1446
1711
  // Probe 3: a simple plugin ping (fall back gracefully if endpoint absent).
1447
1712
  await probe('respira_ping', 'GET', `${baseUrl}/wp-json/respira/v1/ping`);
1713
+ // Probe 4 (v6.11.2): the `?rest_route=` form against the site root. Confirms
1714
+ // whether the site has rewrite shadowing on `/wp-json/[anything]` (plugin
1715
+ // or theme rewrite rule rewriting the path to `index.php` without
1716
+ // `?rest_route=`, which causes WordPress's `redirect_canonical()` to 301
1717
+ // the request back to the homepage). Probe length BEFORE we infer the
1718
+ // fallback worked.
1719
+ const restRoutePingUrl = `${baseUrl}/?rest_route=/respira/v1/ping`;
1720
+ await probe('respira_ping_rest_route', 'GET', restRoutePingUrl);
1448
1721
  // Plugin diagnostic — go through the standard client so we share auth and
1449
1722
  // pick up errors via the existing handler if the endpoint isn't routed.
1450
1723
  let pluginDiagnostic = null;
@@ -1482,6 +1755,29 @@ export class WordPressClient {
1482
1755
  if (this.lastHtmlInsteadOfJson && !htmlInsteadOfJson) {
1483
1756
  recommendations.push(`Earlier in this session a Respira REST call returned HTML (status ${this.lastHtmlInsteadOfJson.status}, title '${this.lastHtmlInsteadOfJson.titleText ?? 'unknown'}'). Edge layer interception may be intermittent.`);
1484
1757
  }
1758
+ // v6.11.2: derive whether the `?rest_route=` fallback would work. The
1759
+ // pretty-permalink probes (probe 2 / probe 3) hit `/wp-json/respira/...`,
1760
+ // and the explicit fallback probe (probe 4) hit `/?rest_route=/respira/...`.
1761
+ // If the pretty path returned HTML and the rest_route path returned valid
1762
+ // JSON, the site has REST rewrite shadowing — and the auto-fallback in
1763
+ // wordpress-client.ts will work transparently.
1764
+ const prettyPathProbe = probes.find((p) => p.label === 'respira_ping');
1765
+ const restRouteProbe = probes.find((p) => p.label === 'respira_ping_rest_route');
1766
+ const prettyLooksHtml = !!prettyPathProbe?.looks_like_html;
1767
+ const restRouteIsJson = !!restRouteProbe &&
1768
+ typeof restRouteProbe.status === 'number' &&
1769
+ restRouteProbe.status >= 200 &&
1770
+ restRouteProbe.status < 300 &&
1771
+ restRouteProbe.looks_like_html === false;
1772
+ const restRouteFallbackWorked = prettyLooksHtml && restRouteIsJson;
1773
+ if (restRouteFallbackWorked) {
1774
+ this.lastRestRouteFallbackWorked = true;
1775
+ recommendations.push('Pretty `/wp-json/respira/...` path returned HTML but `?rest_route=/respira/...` returned JSON. ' +
1776
+ 'A plugin or theme rewrite rule is shadowing `/wp-json/[anything]` and triggering ' +
1777
+ 'WordPress `redirect_canonical()` (look for `x-redirect-by: WordPress` on the 301). ' +
1778
+ 'The MCP server will auto-fall-back to `?rest_route=` for this session — set ' +
1779
+ '`forceRestRoute: true` in the site config to skip the pretty-permalink probe entirely.');
1780
+ }
1485
1781
  return {
1486
1782
  success: true,
1487
1783
  site: {
@@ -1496,6 +1792,10 @@ export class WordPressClient {
1496
1792
  edge_hints: edgeHints,
1497
1793
  html_instead_of_json: htmlInsteadOfJson,
1498
1794
  last_html_observation: this.lastHtmlInsteadOfJson,
1795
+ // v6.11.2: rewrite-shadowing detection + sticky-flag state.
1796
+ rest_route_fallback_worked: restRouteFallbackWorked,
1797
+ rest_route_fallback_active: this.useRestRouteFallback,
1798
+ force_rest_route_configured: this.siteConfig.forceRestRoute === true,
1499
1799
  target_post_id: opts.post_id ?? null,
1500
1800
  recommendations,
1501
1801
  };