@respira/wordpress-mcp-server 8.3.25 → 8.3.27
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 +65 -0
- package/README.md +17 -0
- 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__/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-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/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/server.d.ts.map +1 -1
- package/dist/server.js +103 -19
- package/dist/server.js.map +1 -1
- package/dist/wordpress-client.d.ts +95 -3
- package/dist/wordpress-client.d.ts.map +1 -1
- package/dist/wordpress-client.js +319 -25
- package/dist/wordpress-client.js.map +1 -1
- package/package.json +1 -1
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
|
}
|
|
@@ -3435,7 +3698,7 @@ export class WordPressClient {
|
|
|
3435
3698
|
/**
|
|
3436
3699
|
* Inject page builder content
|
|
3437
3700
|
*/
|
|
3438
|
-
async injectBuilderContent(builder, pageId, content, diviVersion, editTarget, mode, confirmReplace) {
|
|
3701
|
+
async injectBuilderContent(builder, pageId, content, diviVersion, editTarget, mode, confirmReplace, expectedVersion) {
|
|
3439
3702
|
if (builder.toLowerCase() === 'divi') {
|
|
3440
3703
|
if (!diviVersion) {
|
|
3441
3704
|
const existing = await this.extractBuilderContent('divi', pageId);
|
|
@@ -3482,6 +3745,7 @@ export class WordPressClient {
|
|
|
3482
3745
|
editTarget,
|
|
3483
3746
|
mode: mode || 'replace',
|
|
3484
3747
|
confirm_replace: confirmReplace === true,
|
|
3748
|
+
...(expectedVersion ? { expected_version: expectedVersion } : {}),
|
|
3485
3749
|
});
|
|
3486
3750
|
return response.data;
|
|
3487
3751
|
}
|
|
@@ -3576,7 +3840,7 @@ export class WordPressClient {
|
|
|
3576
3840
|
/**
|
|
3577
3841
|
* Update a specific module in a page builder page
|
|
3578
3842
|
*/
|
|
3579
|
-
async updateModule(builder, pageId, moduleIdentifier, updates, editTarget) {
|
|
3843
|
+
async updateModule(builder, pageId, moduleIdentifier, updates, editTarget, expectedVersion) {
|
|
3580
3844
|
try {
|
|
3581
3845
|
// Increase timeout for large content updates
|
|
3582
3846
|
const timeout = updates.content && updates.content.length > 50000
|
|
@@ -3586,6 +3850,7 @@ export class WordPressClient {
|
|
|
3586
3850
|
module_identifier: moduleIdentifier,
|
|
3587
3851
|
updates,
|
|
3588
3852
|
editTarget,
|
|
3853
|
+
...(expectedVersion ? { expected_version: expectedVersion } : {}),
|
|
3589
3854
|
}, { timeout });
|
|
3590
3855
|
return response.data;
|
|
3591
3856
|
}
|
|
@@ -3714,7 +3979,7 @@ export class WordPressClient {
|
|
|
3714
3979
|
/**
|
|
3715
3980
|
* Apply v2 builder patch operations.
|
|
3716
3981
|
*/
|
|
3717
|
-
async applyBuilderPatch(builder, postId, operations, include, editTarget) {
|
|
3982
|
+
async applyBuilderPatch(builder, postId, operations, include, editTarget, expectedVersion) {
|
|
3718
3983
|
await this.ensureV2();
|
|
3719
3984
|
// Ticket 9dee06c4: send find_element's vocabulary ({ type: "id", value })
|
|
3720
3985
|
// in the canonical shape ({ id }), so it resolves on plugins that predate
|
|
@@ -3726,6 +3991,9 @@ export class WordPressClient {
|
|
|
3726
3991
|
if (editTarget) {
|
|
3727
3992
|
payload.editTarget = editTarget;
|
|
3728
3993
|
}
|
|
3994
|
+
if (expectedVersion) {
|
|
3995
|
+
payload.expected_version = expectedVersion;
|
|
3996
|
+
}
|
|
3729
3997
|
const response = await this.rootClient.post(`/wp-json/respira/v2/builder/${builder}/patch/${postId}`, payload);
|
|
3730
3998
|
return response.data;
|
|
3731
3999
|
}
|
|
@@ -3974,6 +4242,13 @@ export class WordPressClient {
|
|
|
3974
4242
|
// touching anything. What is being measured is whether the verb reaches
|
|
3975
4243
|
// PHP at all, not whether the route exists.
|
|
3976
4244
|
await probe('respira_ping_put', 'PUT', `${baseUrl}/wp-json/respira/v1/ping`);
|
|
4245
|
+
// Probe 7 (ticket 087103bc): who answers `/.well-known/`. One request each,
|
|
4246
|
+
// read-only, and only on an explicit diagnose call. ChatGPT's OAuth
|
|
4247
|
+
// discovery looks at the canonical address and nowhere else, so a host that
|
|
4248
|
+
// reserves /.well-known/ for ACME breaks the connection while the plugin is
|
|
4249
|
+
// fine, and nothing inside WordPress can see it happen.
|
|
4250
|
+
await probe('well_known_oauth_protected_resource', 'GET', `${baseUrl}/.well-known/oauth-protected-resource`);
|
|
4251
|
+
await probe('respira_oauth_protected_resource', 'GET', `${baseUrl}/wp-json/respira/v1/oauth-protected-resource`);
|
|
3977
4252
|
// Plugin diagnostic — go through the standard client so we share auth and
|
|
3978
4253
|
// pick up errors via the existing handler if the endpoint isn't routed.
|
|
3979
4254
|
let pluginDiagnostic = null;
|
|
@@ -4233,6 +4508,10 @@ export class WordPressClient {
|
|
|
4233
4508
|
'The MCP server will auto-fall-back to `?rest_route=` for this session — set ' +
|
|
4234
4509
|
'`forceRestRoute: true` in the site config to skip the pretty-permalink probe entirely.');
|
|
4235
4510
|
}
|
|
4511
|
+
const wellKnown = wellKnownOwnership(probes.find((p) => p.label === 'well_known_oauth_protected_resource'), probes.find((p) => p.label === 'respira_oauth_protected_resource'));
|
|
4512
|
+
if (wellKnown.recommendation) {
|
|
4513
|
+
recommendations.push(wellKnown.recommendation);
|
|
4514
|
+
}
|
|
4236
4515
|
return {
|
|
4237
4516
|
success: true,
|
|
4238
4517
|
site: {
|
|
@@ -4240,6 +4519,10 @@ export class WordPressClient {
|
|
|
4240
4519
|
url: this.siteConfig.url,
|
|
4241
4520
|
},
|
|
4242
4521
|
mcp_client_version: MCP_CLIENT_VERSION,
|
|
4522
|
+
// Who serves the OAuth discovery address: 'wordpress_serves_it',
|
|
4523
|
+
// 'host_answers_first' (the host reserves /.well-known/, which is what
|
|
4524
|
+
// breaks ChatGPT discovery), 'both_unreachable' or 'unknown'.
|
|
4525
|
+
well_known_discovery: wellKnown,
|
|
4243
4526
|
plugin_diagnostic: pluginDiagnostic,
|
|
4244
4527
|
plugin_diagnostic_error: pluginDiagnosticError,
|
|
4245
4528
|
outside_probes: probes,
|
|
@@ -4445,12 +4728,13 @@ export class WordPressClient {
|
|
|
4445
4728
|
* back on the second call with the same slug to complete.
|
|
4446
4729
|
*/
|
|
4447
4730
|
async activatePlugin(slug, approvalToken, forceWithoutProbe) {
|
|
4448
|
-
const
|
|
4731
|
+
const target = pluginRouteTarget(slug);
|
|
4732
|
+
const body = { ...target.body };
|
|
4449
4733
|
if (approvalToken)
|
|
4450
4734
|
body.approval_token = approvalToken;
|
|
4451
4735
|
if (forceWithoutProbe)
|
|
4452
4736
|
body.force_without_probe = true;
|
|
4453
|
-
const response = await this.client.post(`/plugins/${encodeURIComponent(
|
|
4737
|
+
const response = await this.client.post(`/plugins/${encodeURIComponent(target.segment)}/activate`, body);
|
|
4454
4738
|
return response.data;
|
|
4455
4739
|
}
|
|
4456
4740
|
/**
|
|
@@ -4458,8 +4742,11 @@ export class WordPressClient {
|
|
|
4458
4742
|
* back on the second call with the same slug to complete.
|
|
4459
4743
|
*/
|
|
4460
4744
|
async deactivatePlugin(slug, approvalToken) {
|
|
4461
|
-
const
|
|
4462
|
-
const
|
|
4745
|
+
const target = pluginRouteTarget(slug);
|
|
4746
|
+
const body = { ...target.body };
|
|
4747
|
+
if (approvalToken)
|
|
4748
|
+
body.approval_token = approvalToken;
|
|
4749
|
+
const response = await this.client.post(`/plugins/${encodeURIComponent(target.segment)}/deactivate`, Object.keys(body).length ? body : undefined);
|
|
4463
4750
|
return response.data;
|
|
4464
4751
|
}
|
|
4465
4752
|
/**
|
|
@@ -4467,8 +4754,11 @@ export class WordPressClient {
|
|
|
4467
4754
|
* back on the second call with the same slug to complete.
|
|
4468
4755
|
*/
|
|
4469
4756
|
async updatePlugin(slug, approvalToken) {
|
|
4470
|
-
const
|
|
4471
|
-
const
|
|
4757
|
+
const target = pluginRouteTarget(slug);
|
|
4758
|
+
const body = { ...target.body };
|
|
4759
|
+
if (approvalToken)
|
|
4760
|
+
body.approval_token = approvalToken;
|
|
4761
|
+
const response = await this.client.post(`/plugins/${encodeURIComponent(target.segment)}/update`, Object.keys(body).length ? body : undefined);
|
|
4472
4762
|
return response.data;
|
|
4473
4763
|
}
|
|
4474
4764
|
/**
|
|
@@ -4496,9 +4786,13 @@ export class WordPressClient {
|
|
|
4496
4786
|
* back on the second call with the same slug to complete.
|
|
4497
4787
|
*/
|
|
4498
4788
|
async deletePlugin(slug, approvalToken) {
|
|
4789
|
+
const target = pluginRouteTarget(slug);
|
|
4790
|
+
const data = { ...target.body };
|
|
4791
|
+
if (approvalToken)
|
|
4792
|
+
data.approval_token = approvalToken;
|
|
4499
4793
|
// axios delete supports a body via the `data` option.
|
|
4500
|
-
const config =
|
|
4501
|
-
const response = await this.client.delete(`/plugins/${encodeURIComponent(
|
|
4794
|
+
const config = Object.keys(data).length ? { data } : undefined;
|
|
4795
|
+
const response = await this.client.delete(`/plugins/${encodeURIComponent(target.segment)}`, config);
|
|
4502
4796
|
return response.data;
|
|
4503
4797
|
}
|
|
4504
4798
|
// Users Management
|