@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.
- package/CHANGELOG.md +67 -0
- package/README.md +114 -20
- package/TOOL_CATALOG.md +445 -307
- package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.d.ts +2 -0
- package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.d.ts.map +1 -0
- package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.js +73 -0
- package/dist/__tests__/087103bc-the-host-answers-well-known-first.test.js.map +1 -0
- package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js +3 -2
- package/dist/__tests__/0e6bd33c-redeem-preserves-per-site-settings.test.js.map +1 -1
- package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.d.ts +2 -0
- package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.d.ts.map +1 -0
- package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.js +106 -0
- package/dist/__tests__/685d8023-a-403-from-the-web-server-shows-its-evidence.test.js.map +1 -0
- package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.d.ts +11 -0
- package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.d.ts.map +1 -0
- package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.js +80 -0
- package/dist/__tests__/81c478df-create-page-duplicate-id-aliases.test.js.map +1 -0
- package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.d.ts +16 -0
- package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.d.ts.map +1 -0
- package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.js +105 -0
- package/dist/__tests__/81c478df-rest-no-route-blames-the-plugin.test.js.map +1 -0
- package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.d.ts +2 -0
- package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.d.ts.map +1 -0
- package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.js +69 -0
- package/dist/__tests__/dfc0e156-plugin-file-with-a-slash.test.js.map +1 -0
- package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.d.ts +15 -0
- package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.d.ts.map +1 -0
- package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.js +61 -0
- package/dist/__tests__/fb4ec63e-update-advice-matches-the-install.test.js.map +1 -0
- package/dist/__tests__/plugin-slug-path-encoding.test.d.ts +2 -0
- package/dist/__tests__/plugin-slug-path-encoding.test.d.ts.map +1 -0
- package/dist/__tests__/plugin-slug-path-encoding.test.js +41 -0
- package/dist/__tests__/plugin-slug-path-encoding.test.js.map +1 -0
- package/dist/__tests__/release-9-surface.test.d.ts +2 -0
- package/dist/__tests__/release-9-surface.test.d.ts.map +1 -0
- package/dist/__tests__/release-9-surface.test.js +197 -0
- package/dist/__tests__/release-9-surface.test.js.map +1 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +57 -0
- package/dist/config.js.map +1 -1
- package/dist/install-skills.d.ts +4 -5
- package/dist/install-skills.d.ts.map +1 -1
- package/dist/install-skills.js +4 -5
- package/dist/install-skills.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +328 -89
- package/dist/server.js.map +1 -1
- package/dist/skill-prompts.d.ts +43 -0
- package/dist/skill-prompts.d.ts.map +1 -0
- package/dist/skill-prompts.js +135 -0
- package/dist/skill-prompts.js.map +1 -0
- package/dist/wordpress-client.d.ts +132 -11
- package/dist/wordpress-client.d.ts.map +1 -1
- package/dist/wordpress-client.js +380 -39
- package/dist/wordpress-client.js.map +1 -1
- package/package.json +2 -2
- package/skills/activity-report-composer/SKILL.md +17 -5
- package/skills/activity-report-composer/metadata.json +2 -2
- package/skills/art-direction/SKILL.md +9 -3
- package/skills/art-direction/metadata.json +7 -3
- package/skills/brand-voice-synthesizer/SKILL.md +36 -16
- package/skills/brand-voice-synthesizer/metadata.json +8 -4
- package/skills/build-oxygen6-page/SKILL.md +17 -2
- package/skills/conversion-audit/SKILL.md +12 -0
- package/skills/custom-post-type-architect/SKILL.md +12 -0
- package/skills/figma-to-bricks/SKILL.md +4 -2
- package/skills/figma-to-divi/SKILL.md +4 -2
- package/skills/figma-to-elementor/SKILL.md +8 -1
- package/skills/figma-to-gutenberg/SKILL.md +4 -2
- package/skills/html-to-breakdance/SKILL.md +7 -3
- package/skills/html-to-breakdance/metadata.json +7 -3
- package/skills/html-to-bricks/SKILL.md +7 -3
- package/skills/html-to-bricks/metadata.json +7 -3
- package/skills/internal-link-builder/SKILL.md +12 -0
- package/skills/migrate-beaver-builder-to-bricks/SKILL.md +19 -3
- package/skills/migrate-beaver-builder-to-gutenberg/SKILL.md +19 -3
- package/skills/migrate-brizy-to-gutenberg/SKILL.md +19 -3
- package/skills/migrate-divi-to-breakdance/SKILL.md +19 -3
- package/skills/migrate-divi-to-bricks/SKILL.md +19 -3
- package/skills/migrate-divi-to-gutenberg/SKILL.md +19 -3
- package/skills/migrate-elementor-to-breakdance/SKILL.md +20 -4
- package/skills/migrate-elementor-to-bricks/SKILL.md +20 -4
- package/skills/migrate-elementor-to-gutenberg/SKILL.md +20 -4
- package/skills/migrate-elementor-to-oxygen/SKILL.md +20 -4
- package/skills/migrate-oxygen-to-breakdance/SKILL.md +19 -3
- package/skills/migrate-oxygen-to-bricks/SKILL.md +19 -3
- package/skills/migrate-thrive-architect-to-gutenberg/SKILL.md +19 -3
- package/skills/migrate-visual-composer-to-gutenberg/SKILL.md +19 -3
- package/skills/migrate-wpbakery-to-bricks/SKILL.md +19 -3
- package/skills/migrate-wpbakery-to-gutenberg/SKILL.md +19 -3
- package/skills/mobile-experience-report/SKILL.md +12 -0
- package/skills/murmur-review-loop/SKILL.md +142 -0
- package/skills/page-template-library/SKILL.md +12 -0
- package/skills/respira-builder-edits/SKILL.md +1 -0
- package/skills/respira-setup-assistant/SKILL.md +12 -0
- package/skills/respira-site-audit/SKILL.md +18 -6
- package/skills/respira-woocommerce/SKILL.md +1 -0
- package/skills/seo-aeo-amplifier/SKILL.md +14 -2
- package/skills/seo-aeo-amplifier/metadata.json +2 -2
- package/skills/stale-content-detector/SKILL.md +12 -0
- package/skills/technical-debt-audit/SKILL.md +14 -2
- package/skills/woo-marketing-campaigns/SKILL.md +12 -9
- package/skills/woo-pricing-promotions/SKILL.md +9 -9
- package/skills/woocommerce-health-check/SKILL.md +22 -8
- package/skills/wordpress-ai-image-optimizer/SKILL.md +14 -2
- package/skills/wordpress-ai-image-optimizer/metadata.json +2 -2
- package/skills/wordpress-security-review/SKILL.md +154 -0
- package/skills/wordpress-site-dna/README.md +1 -1
- package/skills/wordpress-site-dna/SKILL.md +52 -19
- package/tool-capabilities.json +3171 -370
package/dist/wordpress-client.js
CHANGED
|
@@ -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
|
-
|
|
1987
|
-
'
|
|
1988
|
-
|
|
1989
|
-
'
|
|
1990
|
-
'
|
|
1991
|
-
'
|
|
1992
|
-
'
|
|
1993
|
-
'
|
|
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
|
|
2098
|
-
//
|
|
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}
|
|
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
|
-
*
|
|
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(
|
|
4297
|
-
const response = await this.client.
|
|
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
|
-
*
|
|
4302
|
-
*
|
|
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
|
-
*
|
|
4311
|
-
* the
|
|
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
|
|
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
|
|
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
|
|
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/${
|
|
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
|
|
4433
|
-
const
|
|
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(`/
|
|
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 =
|
|
4452
|
-
const response = await this.client.delete(`/plugins/${
|
|
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
|
|
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 || {} });
|