@respira/wordpress-mcp-server 8.3.24 → 8.3.26

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.
Files changed (111) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +114 -20
  3. package/TOOL_CATALOG.md +445 -307
  4. package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.d.ts +2 -0
  5. package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.d.ts.map +1 -0
  6. package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.js +73 -0
  7. package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.js.map +1 -0
  8. package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js +3 -2
  9. package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js.map +1 -1
  10. package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.d.ts +2 -0
  11. package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.d.ts.map +1 -0
  12. package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.js +106 -0
  13. package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.js.map +1 -0
  14. package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.d.ts +11 -0
  15. package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.d.ts.map +1 -0
  16. package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.js +80 -0
  17. package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.js.map +1 -0
  18. package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.d.ts +16 -0
  19. package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.d.ts.map +1 -0
  20. package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.js +105 -0
  21. package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.js.map +1 -0
  22. package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.d.ts +2 -0
  23. package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.d.ts.map +1 -0
  24. package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.js +69 -0
  25. package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.js.map +1 -0
  26. package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.d.ts +15 -0
  27. package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.d.ts.map +1 -0
  28. package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.js +61 -0
  29. package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.js.map +1 -0
  30. package/dist/__tests__/plugin-slug-path-encoding.test.d.ts +2 -0
  31. package/dist/__tests__/plugin-slug-path-encoding.test.d.ts.map +1 -0
  32. package/dist/__tests__/plugin-slug-path-encoding.test.js +41 -0
  33. package/dist/__tests__/plugin-slug-path-encoding.test.js.map +1 -0
  34. package/dist/__tests__/release-9-surface.test.d.ts +2 -0
  35. package/dist/__tests__/release-9-surface.test.d.ts.map +1 -0
  36. package/dist/__tests__/release-9-surface.test.js +197 -0
  37. package/dist/__tests__/release-9-surface.test.js.map +1 -0
  38. package/dist/config.d.ts +31 -0
  39. package/dist/config.d.ts.map +1 -1
  40. package/dist/config.js +57 -0
  41. package/dist/config.js.map +1 -1
  42. package/dist/install-skills.d.ts +4 -5
  43. package/dist/install-skills.d.ts.map +1 -1
  44. package/dist/install-skills.js +4 -5
  45. package/dist/install-skills.js.map +1 -1
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +328 -89
  48. package/dist/server.js.map +1 -1
  49. package/dist/skill-prompts.d.ts +43 -0
  50. package/dist/skill-prompts.d.ts.map +1 -0
  51. package/dist/skill-prompts.js +135 -0
  52. package/dist/skill-prompts.js.map +1 -0
  53. package/dist/wordpress-client.d.ts +132 -11
  54. package/dist/wordpress-client.d.ts.map +1 -1
  55. package/dist/wordpress-client.js +380 -39
  56. package/dist/wordpress-client.js.map +1 -1
  57. package/package.json +2 -2
  58. package/skills/activity-report-composer/SKILL.md +17 -5
  59. package/skills/activity-report-composer/metadata.json +2 -2
  60. package/skills/art-direction/SKILL.md +9 -3
  61. package/skills/art-direction/metadata.json +7 -3
  62. package/skills/brand-voice-synthesizer/SKILL.md +36 -16
  63. package/skills/brand-voice-synthesizer/metadata.json +8 -4
  64. package/skills/build-oxygen6-page/SKILL.md +17 -2
  65. package/skills/conversion-audit/SKILL.md +12 -0
  66. package/skills/custom-post-type-architect/SKILL.md +12 -0
  67. package/skills/figma-to-bricks/SKILL.md +4 -2
  68. package/skills/figma-to-divi/SKILL.md +4 -2
  69. package/skills/figma-to-elementor/SKILL.md +8 -1
  70. package/skills/figma-to-gutenberg/SKILL.md +4 -2
  71. package/skills/html-to-breakdance/SKILL.md +7 -3
  72. package/skills/html-to-breakdance/metadata.json +7 -3
  73. package/skills/html-to-bricks/SKILL.md +7 -3
  74. package/skills/html-to-bricks/metadata.json +7 -3
  75. package/skills/internal-link-builder/SKILL.md +12 -0
  76. package/skills/migrate-beaver-builder-to-bricks/SKILL.md +19 -3
  77. package/skills/migrate-beaver-builder-to-gutenberg/SKILL.md +19 -3
  78. package/skills/migrate-brizy-to-gutenberg/SKILL.md +19 -3
  79. package/skills/migrate-divi-to-breakdance/SKILL.md +19 -3
  80. package/skills/migrate-divi-to-bricks/SKILL.md +19 -3
  81. package/skills/migrate-divi-to-gutenberg/SKILL.md +19 -3
  82. package/skills/migrate-elementor-to-breakdance/SKILL.md +20 -4
  83. package/skills/migrate-elementor-to-bricks/SKILL.md +20 -4
  84. package/skills/migrate-elementor-to-gutenberg/SKILL.md +20 -4
  85. package/skills/migrate-elementor-to-oxygen/SKILL.md +20 -4
  86. package/skills/migrate-oxygen-to-breakdance/SKILL.md +19 -3
  87. package/skills/migrate-oxygen-to-bricks/SKILL.md +19 -3
  88. package/skills/migrate-thrive-architect-to-gutenberg/SKILL.md +19 -3
  89. package/skills/migrate-visual-composer-to-gutenberg/SKILL.md +19 -3
  90. package/skills/migrate-wpbakery-to-bricks/SKILL.md +19 -3
  91. package/skills/migrate-wpbakery-to-gutenberg/SKILL.md +19 -3
  92. package/skills/mobile-experience-report/SKILL.md +12 -0
  93. package/skills/murmur-review-loop/SKILL.md +142 -0
  94. package/skills/page-template-library/SKILL.md +12 -0
  95. package/skills/respira-builder-edits/SKILL.md +1 -0
  96. package/skills/respira-setup-assistant/SKILL.md +12 -0
  97. package/skills/respira-site-audit/SKILL.md +18 -6
  98. package/skills/respira-woocommerce/SKILL.md +1 -0
  99. package/skills/seo-aeo-amplifier/SKILL.md +14 -2
  100. package/skills/seo-aeo-amplifier/metadata.json +2 -2
  101. package/skills/stale-content-detector/SKILL.md +12 -0
  102. package/skills/technical-debt-audit/SKILL.md +14 -2
  103. package/skills/woo-marketing-campaigns/SKILL.md +12 -9
  104. package/skills/woo-pricing-promotions/SKILL.md +9 -9
  105. package/skills/woocommerce-health-check/SKILL.md +22 -8
  106. package/skills/wordpress-ai-image-optimizer/SKILL.md +14 -2
  107. package/skills/wordpress-ai-image-optimizer/metadata.json +2 -2
  108. package/skills/wordpress-security-review/SKILL.md +154 -0
  109. package/skills/wordpress-site-dna/README.md +1 -1
  110. package/skills/wordpress-site-dna/SKILL.md +52 -19
  111. package/tool-capabilities.json +3171 -370
@@ -647,6 +647,38 @@ function findRedirectRefusal(error) {
647
647
  }
648
648
  return null;
649
649
  }
650
+ /**
651
+ * A JavaScript non-value that reached a URL as text.
652
+ *
653
+ * `${undefined}` is a string, and a template-literal route builder will put it
654
+ * in the path without complaint. WordPress then answers rest_no_route, which
655
+ * looks exactly like a route the plugin never registered. Ticket 81c478df:
656
+ * POST /wp-json/respira/v2/pages/undefined/duplicate was reported to the
657
+ * operator as an out-of-date plugin, on a site with no update available.
658
+ */
659
+ const UNUSABLE_PATH_SEGMENTS = new Set(['undefined', 'null', 'nan', '[object object]']);
660
+ /**
661
+ * The first path segment that cannot possibly identify anything, or null.
662
+ * Query strings are ignored: only the path can break route matching.
663
+ */
664
+ function findUnusablePathSegment(url) {
665
+ const path = String(url || '').split('#')[0].split('?')[0];
666
+ for (const rawSegment of path.split('/')) {
667
+ if (!rawSegment)
668
+ continue;
669
+ let segment = rawSegment;
670
+ try {
671
+ segment = decodeURIComponent(rawSegment);
672
+ }
673
+ catch {
674
+ // A malformed escape is not a question this function answers.
675
+ }
676
+ segment = segment.trim();
677
+ if (segment && UNUSABLE_PATH_SEGMENTS.has(segment.toLowerCase()))
678
+ return segment;
679
+ }
680
+ return null;
681
+ }
650
682
  /** Verbs WordPress lets a POST stand in for via `_method` / X-HTTP-Method-Override. */
651
683
  const METHOD_OVERRIDE_VERBS = new Set(['PUT', 'PATCH', 'DELETE']);
652
684
  function headerValue(headers, name) {
@@ -698,6 +730,183 @@ function writeWasAnsweredAsRead(response, sentMethod) {
698
730
  return false;
699
731
  return /^[A-Za-z0-9._:-]{8,128}$/.test(headerValue(response?.config?.headers, 'Idempotency-Key'));
700
732
  }
733
+ /**
734
+ * Who answers `/.well-known/oauth-protected-resource`: WordPress, or the host?
735
+ *
736
+ * Ticket 087103bc. Managed hosts commonly answer everything under
737
+ * `/.well-known/` themselves so ACME challenges keep working, and a request
738
+ * for anything else there gets the host's own 404 page without WordPress ever
739
+ * running. ChatGPT only ever looks at that address, so the connection fails
740
+ * while the plugin is perfectly fine: on the site in the ticket, an nginx
741
+ * SiteGround install, `/.well-known/oauth-protected-resource` answered a host
742
+ * 404 titled "404 - Not found" with no WordPress markers in it, while
743
+ * `/wp-json/respira/v1/oauth-protected-resource` answered 200 JSON.
744
+ *
745
+ * A body counts as WordPress' own when it is the metadata document (it names a
746
+ * `resource`), or when it carries something only WordPress emits (wp-content,
747
+ * wp-includes, wp-json, the api.w.org link rel). A host 404 has none of those,
748
+ * and neither does a CDN error page.
749
+ *
750
+ * Reads two probes the caller already made. No network of its own.
751
+ */
752
+ export function wellKnownOwnership(wellKnown, restMirror) {
753
+ const evidence = {
754
+ well_known_url: wellKnown?.url ?? null,
755
+ well_known_status: wellKnown?.status ?? null,
756
+ well_known_title: wellKnown?.title_text ?? null,
757
+ well_known_looks_like_wordpress: null,
758
+ rest_mirror_url: restMirror?.url ?? null,
759
+ rest_mirror_status: restMirror?.status ?? null,
760
+ };
761
+ if (!wellKnown || typeof wellKnown.status !== 'number') {
762
+ evidence.note =
763
+ 'The /.well-known/ probe did not come back, so nothing can be said about who answers it.';
764
+ return { verdict: 'unknown', evidence, recommendation: null };
765
+ }
766
+ const body = String(wellKnown.body_snippet ?? '');
767
+ const isMetadata = wellKnown.status >= 200 && wellKnown.status < 300 && /"resource"\s*:/.test(body);
768
+ const hasWordPressMarkers = /wp-content|wp-includes|wp-json|api\.w\.org/i.test(`${body} ${String(wellKnown.headers?.link ?? '')}`);
769
+ const servedByWordPress = isMetadata || hasWordPressMarkers;
770
+ evidence.well_known_looks_like_wordpress = servedByWordPress;
771
+ const mirrorOk = !!restMirror &&
772
+ typeof restMirror.status === 'number' &&
773
+ restMirror.status >= 200 &&
774
+ restMirror.status < 300;
775
+ if (servedByWordPress) {
776
+ return { verdict: 'wordpress_serves_it', evidence, recommendation: null };
777
+ }
778
+ if (!mirrorOk) {
779
+ evidence.note =
780
+ 'Neither address answered as WordPress, so this is not the host reserving /.well-known/: the plugin route did not answer either.';
781
+ return { verdict: 'both_unreachable', evidence, recommendation: null };
782
+ }
783
+ return {
784
+ verdict: 'host_answers_first',
785
+ evidence,
786
+ recommendation: `The host answers ${wellKnown.url} itself, before WordPress. It returned ${wellKnown.status}` +
787
+ (wellKnown.title_text ? ` with a page titled "${wellKnown.title_text}"` : '') +
788
+ ', and that body carries no WordPress marker, while ' +
789
+ `${restMirror?.url} returned ${restMirror?.status} as WordPress. ` +
790
+ 'ChatGPT only checks the /.well-known/ address during discovery, so the connection fails there even though the ' +
791
+ 'plugin is working. The fix is one line for the host: let WordPress handle requests under /.well-known/ that are ' +
792
+ 'not ACME challenges, meaning everything except /.well-known/acme-challenge/.',
793
+ };
794
+ }
795
+ /**
796
+ * The evidence a web-server 403 leaves behind, in the words of the response.
797
+ *
798
+ * Ticket 685d8023. Uploads above roughly 500 bytes were refused with a 403 on
799
+ * one site while a 250-byte upload went through, and the answer was a ranked
800
+ * list of things it might be. That list was a guess, and the customer spent
801
+ * the afternoon checking Cloudflare rules that were not the cause.
802
+ *
803
+ * This reports only what is in the request and the response: the verb and
804
+ * path, how many bytes the body carried, the headers that name the layer, and
805
+ * the absence of the header the Respira plugin stamps on every REST response
806
+ * it serves (X-Respira-Plugin-Version). That absence is the proof that
807
+ * WordPress never ran, which also means the call wrote nothing.
808
+ *
809
+ * The upload path is already multipart/form-data, the shape a WAF is least
810
+ * likely to object to, so there is no smaller or plainer request left to try
811
+ * from this side. That is why the message asks the host for the rule instead
812
+ * of promising a workaround.
813
+ */
814
+ export function nonWordPressBlockEvidence(error) {
815
+ const response = error?.response ?? {};
816
+ const config = error?.config ?? {};
817
+ const headers = {};
818
+ Object.entries(response.headers || {}).forEach(([key, value]) => {
819
+ headers[String(key).toLowerCase()] = String(value);
820
+ });
821
+ const method = String(config.method || '').toUpperCase() || 'the request';
822
+ const path = String(config.url || '') || 'the endpoint';
823
+ const bodyBytes = (() => {
824
+ const declared = Number(config.headers?.['Content-Length'] ?? config.headers?.['content-length']);
825
+ if (Number.isFinite(declared) && declared > 0)
826
+ return declared;
827
+ const data = config.data;
828
+ if (data == null)
829
+ return 0;
830
+ if (typeof data === 'string')
831
+ return Buffer.byteLength(data);
832
+ if (Buffer.isBuffer(data))
833
+ return data.length;
834
+ try {
835
+ return Buffer.byteLength(JSON.stringify(data));
836
+ }
837
+ catch {
838
+ return 0;
839
+ }
840
+ })();
841
+ const contentType = String(config.headers?.['Content-Type'] ?? config.headers?.['content-type'] ?? '');
842
+ const named = [];
843
+ for (const header of ['server', 'cf-ray', 'x-sucuri-id', 'x-sucuri-block', 'x-powered-by', 'x-litespeed-cache']) {
844
+ if (headers[header])
845
+ named.push(`${header}: ${headers[header]}`);
846
+ }
847
+ const lines = [];
848
+ lines.push(`Evidence from this request: ${method} ${path}` +
849
+ (bodyBytes ? `, a ${bodyBytes.toLocaleString('en-US')} byte body` : ', no body') +
850
+ (contentType ? ` sent as ${contentType.split(';')[0]}` : '') +
851
+ '.');
852
+ lines.push(headers['x-respira-plugin-version']
853
+ ? `The response carries X-Respira-Plugin-Version: ${headers['x-respira-plugin-version']}, so WordPress did answer.`
854
+ : 'The response carries no X-Respira-Plugin-Version header, which the Respira plugin stamps on every REST response it serves. WordPress never ran, so nothing was written and the call is safe to retry once the block is lifted.');
855
+ lines.push(named.length
856
+ ? `Response headers that name the layer: ${named.join('; ')}.`
857
+ : 'The response named no server or CDN in its headers, so the layer that refused it did not identify itself.');
858
+ return lines.join(' ');
859
+ }
860
+ /**
861
+ * What to ask the host, with the exact request in it.
862
+ */
863
+ export function nonWordPressBlockAsk(error) {
864
+ const config = error?.config ?? {};
865
+ const method = String(config.method || 'POST').toUpperCase();
866
+ const path = String(config.url || '');
867
+ // The base URL carries the namespace the request actually used, so the
868
+ // address handed to the host is the one in their access log, not a guess.
869
+ const base = String(config.baseURL || '').replace(/\/+$/, '');
870
+ const full = path.startsWith('http')
871
+ ? path
872
+ : base
873
+ ? `${base}${path}`
874
+ : `/wp-json/respira/v1${path}`;
875
+ return ('What to ask the host, word for word: "A ' +
876
+ method +
877
+ ' to ' +
878
+ full +
879
+ ' is answered 403 by the web server before PHP runs. Please find the rule that rejected it in the ' +
880
+ 'ModSecurity audit log or the WAF event log at that timestamp, tell me the rule id, and allow that path." ' +
881
+ 'The rule id is the one fact that ends this; everything else is guesswork on both sides.');
882
+ }
883
+ /**
884
+ * Split a plugin identifier into the path segment and the body field.
885
+ *
886
+ * Ticket dfc0e156. WordPress names a plugin by its file: `WP PROTECTION/
887
+ * SECURITY.php`. That slash cannot travel inside one URL path segment. Encoded
888
+ * as `%2F`, Apache answers 404 before PHP runs unless AllowEncodedSlashes is
889
+ * on, and the servers that do pass it through hand WordPress a decoded slash,
890
+ * which the single-segment route does not match. Both attempts in the report
891
+ * came back `route_not_found`.
892
+ *
893
+ * So the folder goes in the path, where it is always one segment, and the
894
+ * exact file goes in the body as `plugin_file`. A plugin 9.0.0 site that does
895
+ * not know `plugin_file` resolves the folder on its own and still acts on the
896
+ * right plugin; a single-file plugin (`hello.php`) has no slash and is
897
+ * unchanged.
898
+ */
899
+ export function pluginRouteTarget(slug) {
900
+ const identifier = String(slug ?? '').trim().replace(/^\/+/, '');
901
+ const separator = identifier.indexOf('/');
902
+ if (separator === -1) {
903
+ return { segment: identifier, body: {} };
904
+ }
905
+ return {
906
+ segment: identifier.slice(0, separator),
907
+ body: { plugin_file: identifier },
908
+ };
909
+ }
701
910
  export class WordPressClient {
702
911
  client;
703
912
  rootClient;
@@ -1891,6 +2100,53 @@ export class WordPressClient {
1891
2100
  return null;
1892
2101
  }
1893
2102
  }
2103
+ /**
2104
+ * Explain a `rest_no_route` that landed under a namespace WordPress DID
2105
+ * register.
2106
+ *
2107
+ * This used to say one thing and one thing only: "the installed plugin is
2108
+ * older than this tool. Update Respira in wp-admin under Plugins." Ticket
2109
+ * 81c478df came from a site running 8.9.1 with no update offered anywhere.
2110
+ * The real cause was the request path, which carried the literal string
2111
+ * `undefined` where the page id belonged. The operator was sent to wp-admin
2112
+ * three times across two sessions to install an update that did not exist,
2113
+ * and the one fact that would have ended it in seconds, the malformed
2114
+ * segment, was sitting in the message already.
2115
+ *
2116
+ * So say only what can be shown:
2117
+ *
2118
+ * 1. An unusable path segment is visible in the URL. Nothing about the
2119
+ * plugin's age explains it and no update fixes it.
2120
+ * 2. The plugin publishes a max_mcp_version below this build. That is the
2121
+ * plugin declaring, in its own words, that it does not know this tool
2122
+ * version, which is the only evidence that supports "update the plugin".
2123
+ * 3. Neither. Then the route is missing for a reason this process cannot
2124
+ * see, and guessing at the version costs the operator an afternoon.
2125
+ */
2126
+ async explainMissingSubroute(reqUrl) {
2127
+ const lead = 'The Respira namespace IS registered, so the plugin is active and REST works: this specific route is missing.';
2128
+ const badSegment = findUnusablePathSegment(reqUrl);
2129
+ if (badSegment) {
2130
+ return (`${lead} The request carried "${badSegment}" where the route expects an id, so no route could match it. ` +
2131
+ 'That is a bad argument on this call, not an out-of-date plugin: retry with the numeric id, ' +
2132
+ 'checking the tool schema for the parameter name it wants.');
2133
+ }
2134
+ // checkCompatibility() caches, swallows its own errors, and is usually
2135
+ // already resolved by the time any tool errors out.
2136
+ await this.checkCompatibility();
2137
+ const pluginLabel = this.pluginVersion ? `v${this.pluginVersion}` : 'installed version unknown';
2138
+ const outranksPlugin = this.maxMcpVersion
2139
+ ? this.compareSemver(this.parseSemver(MCP_CLIENT_VERSION), this.parseSemver(this.maxMcpVersion)) > 0
2140
+ : false;
2141
+ if (outranksPlugin) {
2142
+ return (`${lead} The plugin (${pluginLabel}) supports Respira MCP up to v${this.maxMcpVersion} and this tool runs ` +
2143
+ `v${MCP_CLIENT_VERSION}, so the route arrived after the plugin did. Update Respira in wp-admin under Plugins.`);
2144
+ }
2145
+ return (`${lead} The plugin (${pluginLabel}) does not report itself as older than this tool ` +
2146
+ `(MCP v${MCP_CLIENT_VERSION}${this.maxMcpVersion ? `, plugin ceiling v${this.maxMcpVersion}` : ', plugin publishes no version ceiling'}), ` +
2147
+ 'so an update is probably not the fix. Check the request path and the arguments first, ' +
2148
+ 'then run respira_diagnose_connection.');
2149
+ }
1894
2150
  async handleError(error) {
1895
2151
  if (error.response) {
1896
2152
  const status = error.response.status;
@@ -1982,15 +2238,25 @@ export class WordPressClient {
1982
2238
  // the wrong place. G.B. at denovoagents.com lost roughly a day to it
1983
2239
  // in August 2026 and found it himself; the old wording here pointed
1984
2240
  // him at plugins, which was not where the block lived.
2241
+ // Ticket 685d8023: the ranked list below used to be the whole
2242
+ // answer, and it sent a customer through Cloudflare rules that were
2243
+ // not the cause while uploads over ~500 bytes kept failing and a
2244
+ // 250-byte one succeeded. The evidence comes first now, and the
2245
+ // guesses are labelled as the usual suspects rather than as a
2246
+ // diagnosis. The upload body is already multipart/form-data, so
2247
+ // there is no plainer shape left to send from this side.
1985
2248
  return this.codedError(`Site blocked: ${this.siteConfig.url} returned a 403 Forbidden from the web server (not WordPress).\n\n` +
1986
- 'Something in front of WordPress is refusing the request, so this is not a plugin or license problem. ' +
1987
- 'In order of how often it turns out to be the cause:\n' +
1988
- '1. Cloudflare bot protection. "Block AI Scrapers and Crawlers", Bot Fight Mode, or a WAF rule. ' +
1989
- 'This is the most common cause and it is invisible from inside WordPress. ' +
1990
- 'Cloudflare dashboard: Security > Bots, and Security > WAF.\n' +
1991
- '2. A security plugin such as Wordfence or Sucuri.\n' +
1992
- '3. A host-level firewall or ModSecurity rule.\n\n' +
1993
- 'The fix in every case is to allow the /wp-json/respira/* path, or the MCP server\'s User-Agent, through that layer.', 'http_403_non_wordpress');
2249
+ nonWordPressBlockEvidence(error) +
2250
+ '\n\n' +
2251
+ nonWordPressBlockAsk(error) +
2252
+ '\n\n' +
2253
+ 'Where this usually lives, in order, none of it confirmed by the response above: ' +
2254
+ 'Cloudflare bot protection ("Block AI Scrapers and Crawlers", Bot Fight Mode, or a WAF rule, ' +
2255
+ 'under Security > Bots and Security > WAF); a security plugin such as Wordfence or Sucuri; ' +
2256
+ 'a host-level firewall or ModSecurity rule. ' +
2257
+ 'The fix in every case is to allow the /wp-json/respira/* path, or this connector\'s User-Agent, through that layer. ' +
2258
+ 'When small requests to the same path succeed and larger ones do not, the rule is matching on request size or on the ' +
2259
+ 'body, so the size that first fails is worth quoting to the host too.', 'http_403_non_wordpress');
1994
2260
  }
1995
2261
  // Format error with instructions if available
1996
2262
  return this.formatErrorWithInstructions(data);
@@ -2094,9 +2360,8 @@ export class WordPressClient {
2094
2360
  // above) and retrying cannot help, because ?rest_route= reaches the
2095
2361
  // same WordPress and gets the same answer. Either the whole respira
2096
2362
  // namespace is missing, meaning the plugin is not active, or the
2097
- // namespace is there and this one route is not, meaning the plugin
2098
- // is older than the tool. Those need opposite fixes, and telling
2099
- // someone to resave permalinks helps with neither.
2363
+ // namespace is there and this one route is not. Those need opposite
2364
+ // fixes, and telling someone to resave permalinks helps with neither.
2100
2365
  const present = await this.isRespiraNamespaceRegistered();
2101
2366
  if (present === false) {
2102
2367
  apiError.message =
@@ -2107,9 +2372,7 @@ export class WordPressClient {
2107
2372
  }
2108
2373
  else if (present === true) {
2109
2374
  apiError.message =
2110
- `${apiError.message} The Respira namespace IS registered, so the plugin is active and REST works: ` +
2111
- `this specific route is missing, which means the installed plugin is older than this tool. ` +
2112
- `Update Respira in wp-admin under Plugins.`;
2375
+ `${apiError.message} ${await this.explainMissingSubroute(reqUrl)}`;
2113
2376
  }
2114
2377
  }
2115
2378
  }
@@ -3974,6 +4237,13 @@ export class WordPressClient {
3974
4237
  // touching anything. What is being measured is whether the verb reaches
3975
4238
  // PHP at all, not whether the route exists.
3976
4239
  await probe('respira_ping_put', 'PUT', `${baseUrl}/wp-json/respira/v1/ping`);
4240
+ // Probe 7 (ticket 087103bc): who answers `/.well-known/`. One request each,
4241
+ // read-only, and only on an explicit diagnose call. ChatGPT's OAuth
4242
+ // discovery looks at the canonical address and nowhere else, so a host that
4243
+ // reserves /.well-known/ for ACME breaks the connection while the plugin is
4244
+ // fine, and nothing inside WordPress can see it happen.
4245
+ await probe('well_known_oauth_protected_resource', 'GET', `${baseUrl}/.well-known/oauth-protected-resource`);
4246
+ await probe('respira_oauth_protected_resource', 'GET', `${baseUrl}/wp-json/respira/v1/oauth-protected-resource`);
3977
4247
  // Plugin diagnostic — go through the standard client so we share auth and
3978
4248
  // pick up errors via the existing handler if the endpoint isn't routed.
3979
4249
  let pluginDiagnostic = null;
@@ -4233,6 +4503,10 @@ export class WordPressClient {
4233
4503
  'The MCP server will auto-fall-back to `?rest_route=` for this session — set ' +
4234
4504
  '`forceRestRoute: true` in the site config to skip the pretty-permalink probe entirely.');
4235
4505
  }
4506
+ const wellKnown = wellKnownOwnership(probes.find((p) => p.label === 'well_known_oauth_protected_resource'), probes.find((p) => p.label === 'respira_oauth_protected_resource'));
4507
+ if (wellKnown.recommendation) {
4508
+ recommendations.push(wellKnown.recommendation);
4509
+ }
4236
4510
  return {
4237
4511
  success: true,
4238
4512
  site: {
@@ -4240,6 +4514,10 @@ export class WordPressClient {
4240
4514
  url: this.siteConfig.url,
4241
4515
  },
4242
4516
  mcp_client_version: MCP_CLIENT_VERSION,
4517
+ // Who serves the OAuth discovery address: 'wordpress_serves_it',
4518
+ // 'host_answers_first' (the host reserves /.well-known/, which is what
4519
+ // breaks ChatGPT discovery), 'both_unreachable' or 'unknown'.
4520
+ well_known_discovery: wellKnown,
4243
4521
  plugin_diagnostic: pluginDiagnostic,
4244
4522
  plugin_diagnostic_error: pluginDiagnosticError,
4245
4523
  outside_probes: probes,
@@ -4291,24 +4569,33 @@ export class WordPressClient {
4291
4569
  return response.data;
4292
4570
  }
4293
4571
  /**
4294
- * Get Core Web Vitals
4572
+ * Core Web Vitals, measured by PageSpeed Insights.
4573
+ *
4574
+ * This used to read /analyze/core-web-vitals, which estimates LCP/FID/CLS
4575
+ * from static page analysis. Skills and the AER shortcut that call it by
4576
+ * name all expect field data, so since 9.0 it runs the same PSI audit as
4577
+ * runPageSpeedAudit with one strategy. The plugin route is unchanged, so
4578
+ * older MCP servers keep getting the estimate they always did.
4295
4579
  */
4296
- async getCoreWebVitals(pageId) {
4297
- const response = await this.client.get(`/analyze/core-web-vitals/${pageId}`);
4580
+ async getCoreWebVitals(opts) {
4581
+ const response = await this.client.post('/pagespeed/audit', {
4582
+ page_id: opts.page_id,
4583
+ url: opts.url,
4584
+ strategy: opts.strategy === 'desktop' ? 'desktop' : 'mobile',
4585
+ });
4298
4586
  return response.data;
4299
4587
  }
4300
4588
  /**
4301
- * Phase E Tier 2 (v6.19.0): real PageSpeed audit via Google PSI v5.
4302
- * Returns Lighthouse lab data + CrUX field data normalized to the
4303
- * Respira_PageSpeed_Client contract.
4589
+ * Real PageSpeed audit via Google PSI v5. Returns Lighthouse lab data +
4590
+ * CrUX field data normalized to the Respira_PageSpeed_Client contract.
4304
4591
  */
4305
4592
  async runPageSpeedAudit(opts) {
4306
4593
  const response = await this.client.post('/pagespeed/audit', opts);
4307
4594
  return response.data;
4308
4595
  }
4309
4596
  /**
4310
- * Phase E Tier 2 (v6.19.0): analyzer-envelope wrapper around PSI. Feeds
4311
- * the Health tab composite via the `pagespeed` slot.
4597
+ * Analyzer-envelope wrapper around PSI. Feeds the Health tab composite via
4598
+ * the `pagespeed` slot.
4312
4599
  */
4313
4600
  async analyzePagespeed(opts) {
4314
4601
  const response = await this.client.post('/analyze/pagespeed', opts);
@@ -4364,13 +4651,33 @@ export class WordPressClient {
4364
4651
  return response.data;
4365
4652
  }
4366
4653
  /**
4367
- * Collect bounded, read-only security evidence.
4654
+ * Collect bounded security evidence. Reads only, unless the caller asks for
4655
+ * the uploads execution probe, which writes and deletes one inert file.
4368
4656
  */
4369
- async runSecurityAudit(advisoryIds = [], deepScan = false) {
4370
- const response = await this.client.post('/security/audit', {
4657
+ async runSecurityAudit(advisoryIds = [], deepScan = false, probeUploadsExecution = false, knownVulnerabilities = true) {
4658
+ const body = {
4371
4659
  advisory_ids: advisoryIds,
4372
4660
  deep_scan: deepScan,
4373
- });
4661
+ };
4662
+ // Both flags are sent only when they differ from the plugin's default, so
4663
+ // an older plugin receives exactly the body it always did.
4664
+ if (probeUploadsExecution) {
4665
+ body.probe_uploads_execution = true;
4666
+ }
4667
+ if (!knownVulnerabilities) {
4668
+ body.known_vulnerabilities = false;
4669
+ }
4670
+ const response = await this.client.post('/security/audit', body);
4671
+ return response.data;
4672
+ }
4673
+ /**
4674
+ * The client report for a period, from the site's Respira activity log.
4675
+ * POSTs the existing admin route that backs the wp-admin Activity screen
4676
+ * and the respira/generate-activity-report ability, so it works on every
4677
+ * plugin since 7.1 without a new endpoint.
4678
+ */
4679
+ async generateActivityReport(input = {}) {
4680
+ const response = await this.client.post('/admin/v71/activity-report', input);
4374
4681
  return response.data;
4375
4682
  }
4376
4683
  /**
@@ -4416,12 +4723,13 @@ export class WordPressClient {
4416
4723
  * back on the second call with the same slug to complete.
4417
4724
  */
4418
4725
  async activatePlugin(slug, approvalToken, forceWithoutProbe) {
4419
- const body = {};
4726
+ const target = pluginRouteTarget(slug);
4727
+ const body = { ...target.body };
4420
4728
  if (approvalToken)
4421
4729
  body.approval_token = approvalToken;
4422
4730
  if (forceWithoutProbe)
4423
4731
  body.force_without_probe = true;
4424
- const response = await this.client.post(`/plugins/${slug}/activate`, body);
4732
+ const response = await this.client.post(`/plugins/${encodeURIComponent(target.segment)}/activate`, body);
4425
4733
  return response.data;
4426
4734
  }
4427
4735
  /**
@@ -4429,8 +4737,11 @@ export class WordPressClient {
4429
4737
  * back on the second call with the same slug to complete.
4430
4738
  */
4431
4739
  async deactivatePlugin(slug, approvalToken) {
4432
- const body = approvalToken ? { approval_token: approvalToken } : undefined;
4433
- const response = await this.client.post(`/plugins/${slug}/deactivate`, body);
4740
+ const target = pluginRouteTarget(slug);
4741
+ const body = { ...target.body };
4742
+ if (approvalToken)
4743
+ body.approval_token = approvalToken;
4744
+ const response = await this.client.post(`/plugins/${encodeURIComponent(target.segment)}/deactivate`, Object.keys(body).length ? body : undefined);
4434
4745
  return response.data;
4435
4746
  }
4436
4747
  /**
@@ -4438,8 +4749,31 @@ export class WordPressClient {
4438
4749
  * back on the second call with the same slug to complete.
4439
4750
  */
4440
4751
  async updatePlugin(slug, approvalToken) {
4752
+ const target = pluginRouteTarget(slug);
4753
+ const body = { ...target.body };
4754
+ if (approvalToken)
4755
+ body.approval_token = approvalToken;
4756
+ const response = await this.client.post(`/plugins/${encodeURIComponent(target.segment)}/update`, Object.keys(body).length ? body : undefined);
4757
+ return response.data;
4758
+ }
4759
+ /**
4760
+ * Update a theme (plugin 9.0). Approval-gated like updatePlugin; the
4761
+ * response carries a health check of the site after the update.
4762
+ */
4763
+ async updateTheme(stylesheet, approvalToken) {
4441
4764
  const body = approvalToken ? { approval_token: approvalToken } : undefined;
4442
- const response = await this.client.post(`/plugins/${slug}/update`, body);
4765
+ const response = await this.client.post(`/themes/${encodeURIComponent(stylesheet)}/update`, body);
4766
+ return response.data;
4767
+ }
4768
+ /**
4769
+ * Revoke one application password by user id and uuid (plugin 9.0).
4770
+ * Approval-gated; echo approval_token with the same arguments.
4771
+ */
4772
+ async revokeApplicationPassword(userId, uuid, approvalToken) {
4773
+ const body = { user_id: userId, uuid };
4774
+ if (approvalToken)
4775
+ body.approval_token = approvalToken;
4776
+ const response = await this.client.post('/security/application-passwords/revoke', body);
4443
4777
  return response.data;
4444
4778
  }
4445
4779
  /**
@@ -4447,9 +4781,13 @@ export class WordPressClient {
4447
4781
  * back on the second call with the same slug to complete.
4448
4782
  */
4449
4783
  async deletePlugin(slug, approvalToken) {
4784
+ const target = pluginRouteTarget(slug);
4785
+ const data = { ...target.body };
4786
+ if (approvalToken)
4787
+ data.approval_token = approvalToken;
4450
4788
  // axios delete supports a body via the `data` option.
4451
- const config = approvalToken ? { data: { approval_token: approvalToken } } : undefined;
4452
- const response = await this.client.delete(`/plugins/${slug}`, config);
4789
+ const config = Object.keys(data).length ? { data } : undefined;
4790
+ const response = await this.client.delete(`/plugins/${encodeURIComponent(target.segment)}`, config);
4453
4791
  return response.data;
4454
4792
  }
4455
4793
  // Users Management
@@ -4649,8 +4987,15 @@ export class WordPressClient {
4649
4987
  const response = await this.client.get(`/options/${option}`);
4650
4988
  return response.data;
4651
4989
  }
4652
- async updateOption(option, value) {
4653
- const response = await this.client.post(`/options/${option}`, { value });
4990
+ async updateOption(option, value, flags = {}) {
4991
+ const body = { value };
4992
+ // The plugin's two type-safety overrides, forwarded only when set.
4993
+ for (const flag of ['allow_type_change', 'allow_object_overwrite']) {
4994
+ if (flags[flag] !== undefined) {
4995
+ body[flag] = flags[flag];
4996
+ }
4997
+ }
4998
+ const response = await this.client.post(`/options/${option}`, body);
4654
4999
  return response.data;
4655
5000
  }
4656
5001
  async deleteOption(option, approvalToken) {
@@ -4853,10 +5198,6 @@ export class WordPressClient {
4853
5198
  const response = await this.putWithPostFallback(this.client, `/woocommerce/products/${id}/stock`, data);
4854
5199
  return response.data;
4855
5200
  }
4856
- async woocommerceSalesReport(params) {
4857
- const response = await this.client.get('/woocommerce/reports/sales', { params: params || {} });
4858
- return response.data;
4859
- }
4860
5201
  // WooCommerce Add-on v3.0 — brands
4861
5202
  async woocommerceListBrands(params) {
4862
5203
  const response = await this.client.get('/woocommerce/brands', { params: params || {} });