@respira/wordpress-mcp-server 7.3.0 → 7.4.1
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/dist/__tests__/site-list-self-heal.test.d.ts +18 -0
- package/dist/__tests__/site-list-self-heal.test.d.ts.map +1 -0
- package/dist/__tests__/site-list-self-heal.test.js +169 -0
- package/dist/__tests__/site-list-self-heal.test.js.map +1 -0
- package/dist/acf-tools.d.ts +0 -10
- package/dist/acf-tools.d.ts.map +1 -1
- package/dist/acf-tools.js +0 -15
- package/dist/acf-tools.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +31 -36
- package/dist/config.js.map +1 -1
- package/dist/server.d.ts +34 -46
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +560 -463
- package/dist/server.js.map +1 -1
- package/dist/wordpress-client.d.ts +33 -29
- package/dist/wordpress-client.d.ts.map +1 -1
- package/dist/wordpress-client.js +124 -148
- package/dist/wordpress-client.js.map +1 -1
- package/package.json +2 -2
- package/skills/activity-report-composer/README.md +38 -0
- package/skills/activity-report-composer/SKILL.md +167 -0
- package/skills/activity-report-composer/metadata.json +42 -0
- package/skills/brand-voice-synthesizer/README.md +43 -0
- package/skills/brand-voice-synthesizer/SKILL.md +259 -0
- package/skills/brand-voice-synthesizer/metadata.json +46 -0
- package/skills/build-oxygen6-page/SKILL.md +59 -0
- package/skills/conversion-audit/README.md +37 -0
- package/skills/conversion-audit/SKILL.md +185 -0
- package/skills/conversion-audit/metadata.json +46 -0
- package/skills/custom-post-type-architect/README.md +34 -0
- package/skills/custom-post-type-architect/SKILL.md +158 -0
- package/skills/custom-post-type-architect/metadata.json +39 -0
- package/skills/design-system-synthesizer/README.md +46 -0
- package/skills/design-system-synthesizer/SKILL.md +395 -0
- package/skills/design-system-synthesizer/metadata.json +57 -0
- package/skills/figma-to-elementor/SKILL.md +204 -0
- package/skills/html-to-bricks/README.md +43 -0
- package/skills/html-to-bricks/SKILL.md +135 -0
- package/skills/html-to-bricks/metadata.json +41 -0
- package/skills/internal-link-builder/SKILL.md +258 -0
- package/skills/internal-link-builder/metadata.json +46 -0
- package/skills/migrate-beaver-builder-to-bricks/SKILL.md +251 -0
- package/skills/migrate-beaver-builder-to-gutenberg/SKILL.md +248 -0
- package/skills/migrate-brizy-to-gutenberg/SKILL.md +248 -0
- package/skills/migrate-divi-to-breakdance/SKILL.md +312 -0
- package/skills/migrate-divi-to-bricks/SKILL.md +321 -0
- package/skills/migrate-divi-to-gutenberg/SKILL.md +328 -0
- package/skills/migrate-elementor-to-breakdance/SKILL.md +287 -0
- package/skills/migrate-elementor-to-bricks/SKILL.md +289 -0
- package/skills/migrate-elementor-to-gutenberg/SKILL.md +327 -0
- package/skills/migrate-elementor-to-oxygen/SKILL.md +300 -0
- package/skills/migrate-oxygen-to-breakdance/SKILL.md +235 -0
- package/skills/migrate-oxygen-to-bricks/SKILL.md +237 -0
- package/skills/migrate-thrive-architect-to-gutenberg/SKILL.md +257 -0
- package/skills/migrate-visual-composer-to-gutenberg/SKILL.md +261 -0
- package/skills/migrate-wpbakery-to-bricks/SKILL.md +336 -0
- package/skills/migrate-wpbakery-to-gutenberg/SKILL.md +345 -0
- package/skills/mobile-experience-report/SKILL.md +719 -0
- package/skills/mobile-experience-report/telemetry.ts +137 -0
- package/skills/page-template-library/README.md +41 -0
- package/skills/page-template-library/SKILL.md +175 -0
- package/skills/page-template-library/metadata.json +44 -0
- package/skills/prime-the-agent/README.md +49 -0
- package/skills/prime-the-agent/SKILL.md +225 -0
- package/skills/prime-the-agent/metadata.json +46 -0
- package/skills/respira-setup-assistant/SKILL.md +386 -0
- package/skills/respira-setup-assistant/metadata.json +36 -0
- package/skills/seo-aeo-amplifier/README.md +61 -0
- package/skills/seo-aeo-amplifier/SKILL.md +277 -0
- package/skills/seo-aeo-amplifier/metadata.json +62 -0
- package/skills/stale-content-detector/README.md +41 -0
- package/skills/stale-content-detector/SKILL.md +156 -0
- package/skills/stale-content-detector/metadata.json +43 -0
- package/skills/technical-debt-audit/SKILL.md +691 -0
- package/skills/technical-debt-audit/telemetry.ts +142 -0
- package/skills/woocommerce-health-check/SKILL.md +719 -0
- package/skills/woocommerce-health-check/telemetry.ts +137 -0
- package/skills/wordpress-ai-image-optimizer/README.md +49 -0
- package/skills/wordpress-ai-image-optimizer/SKILL.md +232 -0
- package/skills/wordpress-ai-image-optimizer/metadata.json +63 -0
- package/skills/wordpress-site-dna/README.md +81 -0
- package/skills/wordpress-site-dna/SKILL.md +669 -0
- package/skills/wordpress-site-dna/telemetry.ts +137 -0
- package/dist/__tests__/acf-tool-routing.test.d.ts +0 -2
- package/dist/__tests__/acf-tool-routing.test.d.ts.map +0 -1
- package/dist/__tests__/acf-tool-routing.test.js +0 -37
- package/dist/__tests__/acf-tool-routing.test.js.map +0 -1
- package/dist/__tests__/cowork-bootstrap.test.d.ts +0 -2
- package/dist/__tests__/cowork-bootstrap.test.d.ts.map +0 -1
- package/dist/__tests__/cowork-bootstrap.test.js +0 -61
- package/dist/__tests__/cowork-bootstrap.test.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -15,61 +15,8 @@ import { WordPressClient } from './wordpress-client.js';
|
|
|
15
15
|
import { RespiraVersionChecker } from './version-checker.js';
|
|
16
16
|
import { getBricksTools, dispatchBricksTool } from './bricks-tools.js';
|
|
17
17
|
import { getElementorTools, dispatchElementorTool } from './elementor-tools.js';
|
|
18
|
-
import { getAcfTools,
|
|
18
|
+
import { getAcfTools, ACF_TOOL_NAMES } from './acf-tools.js';
|
|
19
19
|
import { getUsageEmitter, deriveToolKind } from './usage-emitter.js';
|
|
20
|
-
import { loadConfig, saveConfig } from './config.js';
|
|
21
|
-
/**
|
|
22
|
-
* Per-tool argument aliases. AI clients (especially Cowork / Claude desktop)
|
|
23
|
-
* frequently pass a natural-but-wrong key for a value: `pageId` / `page_id`
|
|
24
|
-
* instead of `id`, `filePath` instead of `file`. Because the dispatch never
|
|
25
|
-
* validated required params, a misnamed id silently produced a broken request
|
|
26
|
-
* like `/wp-json/respira/v2/pages/undefined`, and a misnamed file produced an
|
|
27
|
-
* empty upload that failed with "Could not connect — no response received".
|
|
28
|
-
*
|
|
29
|
-
* This map fills the canonical key a handler reads from the first present
|
|
30
|
-
* synonym, and ONLY when the canonical key is absent — an explicit value is
|
|
31
|
-
* never overwritten. Keyed by the canonical (wordpress_*) tool name, i.e.
|
|
32
|
-
* after normalizeToolName().
|
|
33
|
-
*/
|
|
34
|
-
const TOOL_ARG_ALIASES = {
|
|
35
|
-
wordpress_read_page: { id: ['page_id', 'pageId', 'post_id', 'postId'] },
|
|
36
|
-
wordpress_read_post: { id: ['post_id', 'postId', 'page_id', 'pageId'] },
|
|
37
|
-
wordpress_create_page_duplicate: {
|
|
38
|
-
original_id: ['page_id', 'pageId', 'id', 'original_page_id', 'originalId'],
|
|
39
|
-
},
|
|
40
|
-
wordpress_create_post_duplicate: {
|
|
41
|
-
original_id: ['post_id', 'postId', 'id', 'original_post_id', 'originalId'],
|
|
42
|
-
},
|
|
43
|
-
wordpress_get_snapshot: { snapshot_uuid: ['snapshot_id', 'snapshotId', 'uuid', 'id'] },
|
|
44
|
-
wordpress_find_element: { post_id: ['page_id', 'pageId', 'id', 'postId'] },
|
|
45
|
-
wordpress_upload_media: {
|
|
46
|
-
file: ['filePath', 'file_path', 'path', 'url', 'file_url', 'fileUrl'],
|
|
47
|
-
filename: ['file_name', 'fileName', 'name'],
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
/**
|
|
51
|
-
* Fill canonical argument keys from known synonyms, in place. No-op for tools
|
|
52
|
-
* without an alias entry and for args that already carry the canonical key.
|
|
53
|
-
*/
|
|
54
|
-
function normalizeToolArgs(canonicalName, args) {
|
|
55
|
-
if (!args || typeof args !== 'object')
|
|
56
|
-
return;
|
|
57
|
-
const aliases = TOOL_ARG_ALIASES[canonicalName];
|
|
58
|
-
if (!aliases)
|
|
59
|
-
return;
|
|
60
|
-
const record = args;
|
|
61
|
-
const isSet = (v) => v !== undefined && v !== null && v !== '';
|
|
62
|
-
for (const [canonical, synonyms] of Object.entries(aliases)) {
|
|
63
|
-
if (isSet(record[canonical]))
|
|
64
|
-
continue;
|
|
65
|
-
for (const syn of synonyms) {
|
|
66
|
-
if (isSet(record[syn])) {
|
|
67
|
-
record[canonical] = record[syn];
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
20
|
/**
|
|
74
21
|
* Read the server version from package.json at module load time so the
|
|
75
22
|
* MCP handshake, the `instructions` block, and the version-checker all
|
|
@@ -303,15 +250,6 @@ const TOOLS_WITH_PAGINATION_HINT = {
|
|
|
303
250
|
respira_list_users: 'Pass per_page (default 100, recommend 25) and page args plus a search filter to narrow results.',
|
|
304
251
|
respira_list_media: 'Pass per_page (default 100, recommend 25) and page args plus a mime_type filter to narrow results.',
|
|
305
252
|
respira_extract_builder_content: 'Use respira_get_page_outline first for a row-level summary, or pass node_id to extract just the subtree you care about.',
|
|
306
|
-
// capResponseSize is called with the canonical wordpress_* name, so the
|
|
307
|
-
// extract/read tools need wordpress_* keys too or they fall through to the
|
|
308
|
-
// generic hint. Read tools were missing entirely, which is why a large-page
|
|
309
|
-
// read gave no "use get_page_outline + node_id" guidance. (Jeff, 2026-07-09.)
|
|
310
|
-
wordpress_extract_builder_content: 'Use wordpress_get_page_outline first for a row-level summary, or pass node_id to extract just the subtree you care about.',
|
|
311
|
-
respira_read_page: 'Large page. Call respira_get_page_outline first for a row-level map, then respira_extract_builder_content with a node_id to pull one subtree at a time.',
|
|
312
|
-
wordpress_read_page: 'Large page. Call wordpress_get_page_outline first for a row-level map, then wordpress_extract_builder_content with a node_id to pull one subtree at a time.',
|
|
313
|
-
respira_read_post: 'Large post. Call respira_get_page_outline first for a row-level map, then respira_extract_builder_content with a node_id to pull one subtree at a time.',
|
|
314
|
-
wordpress_read_post: 'Large post. Call wordpress_get_page_outline first for a row-level map, then wordpress_extract_builder_content with a node_id to pull one subtree at a time.',
|
|
315
253
|
respira_list_plugins: 'No pagination arg today. The full plugin list is usually small; consider filing a feature request if your site hits the cap here.',
|
|
316
254
|
};
|
|
317
255
|
export class RespiraWordPressServer {
|
|
@@ -325,9 +263,10 @@ export class RespiraWordPressServer {
|
|
|
325
263
|
allowedSites = null;
|
|
326
264
|
/** Whether the plugin version warning has already been shown this session. */
|
|
327
265
|
versionWarningShown = false;
|
|
328
|
-
/** Epoch ms of the last
|
|
266
|
+
/** Epoch ms of the last site-list self-heal against respira.press (throttle). */
|
|
329
267
|
lastSiteRefreshAt = 0;
|
|
330
|
-
|
|
268
|
+
/** Dedupe concurrent self-heals so parallel list_sites calls share one fetch. */
|
|
269
|
+
siteRefreshInFlight = null;
|
|
331
270
|
static MCP_SERVER_VERSION = MCP_SERVER_VERSION;
|
|
332
271
|
/**
|
|
333
272
|
* Normalize a tool name: respira_* → wordpress_* for switch dispatch.
|
|
@@ -570,14 +509,9 @@ Use respira_get_builder_info first to detect which builder is active. Then use t
|
|
|
570
509
|
- _nextNodeId tracking for proper element ID sequencing after injection
|
|
571
510
|
- respira_find_element and respira_update_element work via tree traversal with full element schema
|
|
572
511
|
|
|
573
|
-
### Oxygen
|
|
574
|
-
|
|
575
|
-
-
|
|
576
|
-
- **Oxygen 6 (codename Jenga, the Breakdance engine)** — a different builder, not Classic. Content is a node tree in the _oxygen_data meta, rendered as native OxygenElements\\* / EssentialElements\\* elements, NOT shortcodes. Author with simplified types via wordpress_inject_builder_content / build_page (pass type + settings, Respira maps them): section, heading (settings.text + level h1-h6), text, rich-text, button (text + url), image (url), icon, video, row, column. On an Oxygen 6 site, get_builder_info returns an \`oxygen6\` block with the full per-element schemas and a structure playbook — read it before building.
|
|
577
|
-
- Build with native elements. NEVER put a whole page or section into a single code/html block: it renders but is not editable in the Visual Builder and is the wrong way to build an Oxygen page.
|
|
578
|
-
- Header, footer, and templates are separate post types: oxygen_header, oxygen_footer, oxygen_template. Edit the existing header/footer posts; do not delete them and inline header/footer into every page.
|
|
579
|
-
- A template MUST use a Template Content Area element (OxygenElements\\TemplateContentArea) where the page body should render. Do NOT use a Post Content element on Oxygen 6 — it errors and the page cannot be edited. This is the most common Oxygen 6 template mistake.
|
|
580
|
-
- The richest building blocks (a real Section, Heading, Button with full design controls) come from the "Breakdance Elements for Oxygen" add-on; when it is absent Respira uses the core OxygenElements equivalents, still native and editable.
|
|
512
|
+
### Oxygen (Smart Defaults)
|
|
513
|
+
- Content stored in ct_builder_shortcodes and/or ct_builder_json meta
|
|
514
|
+
- Templates use ct_template post type
|
|
581
515
|
- respira_find_element works via tree traversal on the parsed structure
|
|
582
516
|
|
|
583
517
|
### WPBakery (Smart Defaults)
|
|
@@ -706,124 +640,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
706
640
|
getSiteSummary(site) {
|
|
707
641
|
return site.getSiteSummary(site.getSiteId() === this.defaultSiteId);
|
|
708
642
|
}
|
|
709
|
-
/**
|
|
710
|
-
* Re-pull the account's CURRENT site list from respira.press and merge in any
|
|
711
|
-
* sites that aren't in the running server yet.
|
|
712
|
-
*
|
|
713
|
-
* Why this exists: the server boots from whatever site list was in its config
|
|
714
|
-
* at startup. When the user adds a site on the dashboard afterwards, a
|
|
715
|
-
* long-lived server (Cowork especially) never learns about it and silently
|
|
716
|
-
* serves the stale list — Emil had 4 sites but Cowork only ever saw 3
|
|
717
|
-
* (2026-06-30). Rather than make the user re-paste config and restart, this
|
|
718
|
-
* lets list_sites self-heal: authenticate with a `respira_site_` token the
|
|
719
|
-
* server already holds, fetch the account's canonical config, and register
|
|
720
|
-
* any new sites in-memory (and persist to ~/.respira/config.json when that is
|
|
721
|
-
* the active source).
|
|
722
|
-
*
|
|
723
|
-
* Best-effort and throttled: a network/auth failure is swallowed and the
|
|
724
|
-
* existing list is returned unchanged. Servers with no site token (OAuth-only)
|
|
725
|
-
* or pinned to RESPIRA_CONFIG_B64 still get the in-session merge; only the
|
|
726
|
-
* on-disk persistence is skipped when the file is not the active source.
|
|
727
|
-
*/
|
|
728
|
-
async refreshSitesFromAccount(force = false) {
|
|
729
|
-
const result = { added: [] };
|
|
730
|
-
const now = Date.now();
|
|
731
|
-
if (!force && now - this.lastSiteRefreshAt < RespiraWordPressServer.SITE_REFRESH_MIN_INTERVAL_MS) {
|
|
732
|
-
return result;
|
|
733
|
-
}
|
|
734
|
-
// Pick a live dashboard site token to authenticate the refresh.
|
|
735
|
-
let authToken = '';
|
|
736
|
-
for (const client of this.sites.values()) {
|
|
737
|
-
const key = client.getApiKey();
|
|
738
|
-
if (typeof key === 'string' && key.startsWith('respira_site_')) {
|
|
739
|
-
authToken = key;
|
|
740
|
-
break;
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
if (!authToken) {
|
|
744
|
-
// OAuth-only or token-less server: nothing to authenticate with.
|
|
745
|
-
return result;
|
|
746
|
-
}
|
|
747
|
-
// Stamp before the network call so a hang/failure still respects the
|
|
748
|
-
// throttle and we don't retry on every single list_sites.
|
|
749
|
-
this.lastSiteRefreshAt = now;
|
|
750
|
-
const apiBase = process.env.RESPIRA_API_BASE || 'https://www.respira.press';
|
|
751
|
-
const url = `${apiBase.replace(/\/+$/, '')}/api/mcp/config/refresh`;
|
|
752
|
-
let payload = null;
|
|
753
|
-
try {
|
|
754
|
-
const response = await fetch(url, {
|
|
755
|
-
method: 'POST',
|
|
756
|
-
headers: { 'Content-Type': 'application/json' },
|
|
757
|
-
body: JSON.stringify({ token: authToken }),
|
|
758
|
-
signal: AbortSignal.timeout(8000),
|
|
759
|
-
});
|
|
760
|
-
if (!response.ok) {
|
|
761
|
-
return result;
|
|
762
|
-
}
|
|
763
|
-
payload = await response.json().catch(() => null);
|
|
764
|
-
}
|
|
765
|
-
catch {
|
|
766
|
-
return result;
|
|
767
|
-
}
|
|
768
|
-
const accountSites = payload?.config?.sites;
|
|
769
|
-
if (!Array.isArray(accountSites)) {
|
|
770
|
-
return result;
|
|
771
|
-
}
|
|
772
|
-
for (const site of accountSites) {
|
|
773
|
-
if (!site?.id || !site?.url || !site?.apiKey)
|
|
774
|
-
continue;
|
|
775
|
-
if (this.sites.has(site.id))
|
|
776
|
-
continue;
|
|
777
|
-
const cfg = {
|
|
778
|
-
id: site.id,
|
|
779
|
-
name: site.name || site.id,
|
|
780
|
-
url: site.url,
|
|
781
|
-
apiKey: site.apiKey,
|
|
782
|
-
default: false,
|
|
783
|
-
};
|
|
784
|
-
const client = new WordPressClient(cfg);
|
|
785
|
-
this.sites.set(cfg.id, client);
|
|
786
|
-
if (this.currentSite === null) {
|
|
787
|
-
this.currentSite = client;
|
|
788
|
-
this.defaultSiteId = cfg.id;
|
|
789
|
-
}
|
|
790
|
-
try {
|
|
791
|
-
getUsageEmitter().registerSiteToken(cfg.url, cfg.apiKey);
|
|
792
|
-
}
|
|
793
|
-
catch {
|
|
794
|
-
// Best-effort; usage telemetry never blocks.
|
|
795
|
-
}
|
|
796
|
-
result.added.push(cfg.name);
|
|
797
|
-
}
|
|
798
|
-
// Persist new sites to disk so a restart keeps them — only when the file is
|
|
799
|
-
// the active config source. A RESPIRA_CONFIG_B64- or RESPIRA_CONFIG_FILE-
|
|
800
|
-
// pinned server still has the correct in-memory list this session; writing
|
|
801
|
-
// the default file would not be read on its next boot and could orphan a
|
|
802
|
-
// stray file, so skip it there.
|
|
803
|
-
const fileIsActiveSource = !process.env.RESPIRA_CONFIG_B64?.trim() && !process.env.RESPIRA_CONFIG_FILE?.trim();
|
|
804
|
-
if (result.added.length > 0 && fileIsActiveSource) {
|
|
805
|
-
try {
|
|
806
|
-
const onDisk = loadConfig();
|
|
807
|
-
const known = new Set(onDisk.sites.map((s) => s.id));
|
|
808
|
-
for (const site of accountSites) {
|
|
809
|
-
if (site?.id && site?.url && site?.apiKey && !known.has(site.id)) {
|
|
810
|
-
onDisk.sites.push({
|
|
811
|
-
id: site.id,
|
|
812
|
-
name: site.name || site.id,
|
|
813
|
-
url: site.url,
|
|
814
|
-
apiKey: site.apiKey,
|
|
815
|
-
default: false,
|
|
816
|
-
});
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
saveConfig(onDisk);
|
|
820
|
-
}
|
|
821
|
-
catch {
|
|
822
|
-
// Best-effort; the in-memory merge above is what unblocks this session.
|
|
823
|
-
}
|
|
824
|
-
}
|
|
825
|
-
return result;
|
|
826
|
-
}
|
|
827
643
|
/**
|
|
828
644
|
* N6 fix (v6.19.0): the response envelope `site` field must reflect the
|
|
829
645
|
* client that ACTUALLY serviced the call, not the global default. Pre-fix,
|
|
@@ -915,14 +731,9 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
915
731
|
const apiBase = process.env.RESPIRA_API_BASE || 'https://www.respira.press';
|
|
916
732
|
const url = `${apiBase.replace(/\/+$/, '')}/docs-search?q=${encodeURIComponent(query)}&limit=${limit}`;
|
|
917
733
|
try {
|
|
918
|
-
// Bound the docs fetch (20s) so a slow or unreachable respira.press
|
|
919
|
-
// (a QUIC-hostile network, a proxy, a stalled link) fails fast instead
|
|
920
|
-
// of hanging the tool to the MCP client's multi-minute ceiling.
|
|
921
|
-
// search_docs is a convenience lookup, never worth blocking triage on.
|
|
922
734
|
const response = await fetch(url, {
|
|
923
735
|
method: 'GET',
|
|
924
736
|
headers: { 'User-Agent': `respira-mcp/${MCP_SERVER_VERSION}` },
|
|
925
|
-
signal: AbortSignal.timeout(20_000),
|
|
926
737
|
});
|
|
927
738
|
if (!response.ok) {
|
|
928
739
|
return {
|
|
@@ -1224,41 +1035,159 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
1224
1035
|
url: s.url,
|
|
1225
1036
|
}));
|
|
1226
1037
|
const hasConfigB64 = Boolean(process.env.RESPIRA_CONFIG_B64);
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
// success with the caveat appended to the end of the message; agents glossed
|
|
1232
|
-
// over it and reported "no output", so the user reconnected forever with a
|
|
1233
|
-
// stale token (Sara / crediso.io: repeated redeems, token never updated,
|
|
1234
|
-
// every authenticated call rejected). Returning success:false with the fix
|
|
1235
|
-
// and the exact replacement value up front makes it impossible to miss.
|
|
1236
|
-
if (hasConfigB64) {
|
|
1237
|
-
const newConfigB64 = Buffer.from(JSON.stringify(config)).toString('base64');
|
|
1238
|
-
return {
|
|
1239
|
-
success: false,
|
|
1240
|
-
error: 'config_pinned_to_env',
|
|
1241
|
-
config_b64_active: true,
|
|
1242
|
-
action_required: true,
|
|
1243
|
-
config_path: configPath,
|
|
1244
|
-
sites_count: sites.length,
|
|
1245
|
-
sites,
|
|
1246
|
-
new_config_b64: newConfigB64,
|
|
1247
|
-
message: 'The token was redeemed and written to ~/.respira/config.json, but it will NOT take effect yet: this MCP server is pinned to the RESPIRA_CONFIG_B64 environment variable, which overrides the config file. That is exactly why reconnecting never updates the token and authenticated calls keep failing. ' +
|
|
1248
|
-
'Fix it one of two ways, then FULLY restart the agent: ' +
|
|
1249
|
-
'(1) remove RESPIRA_CONFIG_B64 from this server\'s MCP config so the file just written is used, or ' +
|
|
1250
|
-
'(2) replace the value of RESPIRA_CONFIG_B64 with this updated base64:\n\n' + newConfigB64,
|
|
1251
|
-
};
|
|
1252
|
-
}
|
|
1038
|
+
const baseMessage = `Connected ${sites.length} site${sites.length === 1 ? '' : 's'} via the Respira Cowork token. The config was written to ~/.respira/config.json. Restart this Cowork chat (or open a new one) so the MCP server picks up the new sites.`;
|
|
1039
|
+
const message = hasConfigB64
|
|
1040
|
+
? `${baseMessage} IMPORTANT: your MCP server is currently configured via the RESPIRA_CONFIG_B64 environment variable, which takes precedence over the file on disk. To activate the new site, update RESPIRA_CONFIG_B64 in your agent's MCP server config with the base64-encoded contents of the new ~/.respira/config.json file, then restart the agent.`
|
|
1041
|
+
: baseMessage;
|
|
1253
1042
|
return {
|
|
1254
1043
|
success: true,
|
|
1255
1044
|
sites_count: sites.length,
|
|
1256
1045
|
sites,
|
|
1257
1046
|
config_path: configPath,
|
|
1258
|
-
config_b64_active:
|
|
1259
|
-
message
|
|
1047
|
+
config_b64_active: hasConfigB64,
|
|
1048
|
+
message,
|
|
1260
1049
|
};
|
|
1261
1050
|
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Self-heal the in-memory site list from respira.press.
|
|
1053
|
+
*
|
|
1054
|
+
* The running server boots from whatever site list was frozen into its
|
|
1055
|
+
* config at startup (RESPIRA_CONFIG_B64 env, or ~/.respira/config.json).
|
|
1056
|
+
* When the user later adds a site on the dashboard, that frozen list never
|
|
1057
|
+
* learns about it: RESPIRA_CONFIG_B64 outranks the file (see loadConfig), and
|
|
1058
|
+
* Cowork's sandbox can't even write the file, so `redeem` can't persist a new
|
|
1059
|
+
* site there. The only workaround users found was deleting and reinstalling
|
|
1060
|
+
* the connector for every new site (Mario, 3 sites; Emil, 4 sites Cowork saw
|
|
1061
|
+
* as 3). That is the bug this fixes.
|
|
1062
|
+
*
|
|
1063
|
+
* Here we re-pull the account's CURRENT canonical inventory using a
|
|
1064
|
+
* credential the server already holds (any one live `respira_site_` token)
|
|
1065
|
+
* via /api/mcp/config/refresh, and merge any missing sites straight into the
|
|
1066
|
+
* live `this.sites` map. Because the merge is in-memory it takes effect in
|
|
1067
|
+
* the SAME session with no restart, no re-paste, and no file write, so it
|
|
1068
|
+
* works identically under RESPIRA_CONFIG_B64 and inside Cowork's sandbox.
|
|
1069
|
+
*
|
|
1070
|
+
* Best-effort and non-blocking: any failure (offline, OAuth-only config with
|
|
1071
|
+
* no site token, backend error, timeout) resolves to 0 added and never
|
|
1072
|
+
* throws, so list_sites still returns the sites already known. Throttled to
|
|
1073
|
+
* at most once per RESPIRA_SITE_REFRESH_THROTTLE_MS (default 60s) and
|
|
1074
|
+
* deduped so parallel calls share one fetch.
|
|
1075
|
+
*
|
|
1076
|
+
* @returns number of newly added sites.
|
|
1077
|
+
*/
|
|
1078
|
+
async maybeSelfHealSiteList(force = false) {
|
|
1079
|
+
if (process.env.RESPIRA_DISABLE_SITE_REFRESH === '1') {
|
|
1080
|
+
return 0;
|
|
1081
|
+
}
|
|
1082
|
+
const throttleMs = Number(process.env.RESPIRA_SITE_REFRESH_THROTTLE_MS) || 60_000;
|
|
1083
|
+
if (!force && Date.now() - this.lastSiteRefreshAt < throttleMs) {
|
|
1084
|
+
return 0;
|
|
1085
|
+
}
|
|
1086
|
+
if (this.siteRefreshInFlight) {
|
|
1087
|
+
return this.siteRefreshInFlight;
|
|
1088
|
+
}
|
|
1089
|
+
this.siteRefreshInFlight = this.performSiteRefresh().finally(() => {
|
|
1090
|
+
this.siteRefreshInFlight = null;
|
|
1091
|
+
});
|
|
1092
|
+
return this.siteRefreshInFlight;
|
|
1093
|
+
}
|
|
1094
|
+
async performSiteRefresh() {
|
|
1095
|
+
// Any one live dashboard site token identifies the account. OAuth
|
|
1096
|
+
// per-site tokens (rsp_at_) are not account-wide and are skipped.
|
|
1097
|
+
let token;
|
|
1098
|
+
for (const client of this.sites.values()) {
|
|
1099
|
+
const key = client.getApiKey?.();
|
|
1100
|
+
if (key && key.startsWith('respira_site_')) {
|
|
1101
|
+
token = key;
|
|
1102
|
+
break;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// Stamp the attempt time even when we bail, so a token-less config doesn't
|
|
1106
|
+
// retry the loop on every single list_sites call.
|
|
1107
|
+
this.lastSiteRefreshAt = Date.now();
|
|
1108
|
+
if (!token) {
|
|
1109
|
+
return 0;
|
|
1110
|
+
}
|
|
1111
|
+
const apiBase = process.env.RESPIRA_API_BASE || 'https://www.respira.press';
|
|
1112
|
+
const url = `${apiBase.replace(/\/+$/, '')}/api/mcp/config/refresh`;
|
|
1113
|
+
const timeoutMs = Number(process.env.RESPIRA_SITE_REFRESH_TIMEOUT_MS) || 8000;
|
|
1114
|
+
let payload;
|
|
1115
|
+
try {
|
|
1116
|
+
const response = await fetch(url, {
|
|
1117
|
+
method: 'POST',
|
|
1118
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1119
|
+
body: JSON.stringify({ token }),
|
|
1120
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
1121
|
+
});
|
|
1122
|
+
if (!response.ok) {
|
|
1123
|
+
return 0;
|
|
1124
|
+
}
|
|
1125
|
+
payload = await response.json();
|
|
1126
|
+
}
|
|
1127
|
+
catch {
|
|
1128
|
+
// Offline / proxy / firewall / timeout: never block list_sites on this.
|
|
1129
|
+
return 0;
|
|
1130
|
+
}
|
|
1131
|
+
const sites = payload?.config?.sites;
|
|
1132
|
+
if (!Array.isArray(sites)) {
|
|
1133
|
+
return 0;
|
|
1134
|
+
}
|
|
1135
|
+
const knownUrls = new Set();
|
|
1136
|
+
for (const client of this.sites.values()) {
|
|
1137
|
+
knownUrls.add(this.normalizeSiteUrl(client.getSiteUrl()));
|
|
1138
|
+
}
|
|
1139
|
+
let added = 0;
|
|
1140
|
+
for (const s of sites) {
|
|
1141
|
+
if (!s || typeof s !== 'object' || !s.id || !s.url || !s.apiKey) {
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
// Dedupe by id AND by normalized URL (a stale frozen config may hold the
|
|
1145
|
+
// same site under a different id than the canonical inventory returns).
|
|
1146
|
+
if (this.sites.has(s.id) || knownUrls.has(this.normalizeSiteUrl(s.url))) {
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
try {
|
|
1150
|
+
const client = new WordPressClient({
|
|
1151
|
+
id: s.id,
|
|
1152
|
+
url: s.url,
|
|
1153
|
+
apiKey: s.apiKey,
|
|
1154
|
+
name: s.name || s.id,
|
|
1155
|
+
default: false,
|
|
1156
|
+
});
|
|
1157
|
+
this.sites.set(s.id, client);
|
|
1158
|
+
knownUrls.add(this.normalizeSiteUrl(s.url));
|
|
1159
|
+
if (!this.currentSite) {
|
|
1160
|
+
this.currentSite = client;
|
|
1161
|
+
this.defaultSiteId = s.id;
|
|
1162
|
+
}
|
|
1163
|
+
try {
|
|
1164
|
+
getUsageEmitter().registerSiteToken(s.url, s.apiKey);
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
// usage telemetry never blocks
|
|
1168
|
+
}
|
|
1169
|
+
added += 1;
|
|
1170
|
+
}
|
|
1171
|
+
catch {
|
|
1172
|
+
// A single malformed site entry never aborts the merge.
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
if (added > 0) {
|
|
1176
|
+
console.error(`respira-mcp: self-heal added ${added} site${added === 1 ? '' : 's'} from respira.press ` +
|
|
1177
|
+
`(now ${this.sites.size} connected).`);
|
|
1178
|
+
}
|
|
1179
|
+
return added;
|
|
1180
|
+
}
|
|
1181
|
+
/** Normalize a site URL for cross-config dedupe: scheme/host only, no www, lowercase. */
|
|
1182
|
+
normalizeSiteUrl(raw) {
|
|
1183
|
+
try {
|
|
1184
|
+
const u = new URL(raw);
|
|
1185
|
+
return u.host.replace(/^www\./i, '').toLowerCase() + u.pathname.replace(/\/+$/, '');
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
return String(raw || '').trim().toLowerCase().replace(/\/+$/, '');
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1262
1191
|
/**
|
|
1263
1192
|
* When a WRITE tool runs against the default site because the caller
|
|
1264
1193
|
* omitted site_id and the account has more than one site connected,
|
|
@@ -1368,12 +1297,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
1368
1297
|
// (wordpress_redeem_token) because normalizeToolName rewrites the
|
|
1369
1298
|
// respira_* alias to wordpress_* before dispatch.
|
|
1370
1299
|
const canonical = this.normalizeToolName(name).canonical;
|
|
1371
|
-
// Tolerate natural-but-wrong arg names from AI clients (pageId/page_id
|
|
1372
|
-
// vs id, filePath vs file). Fills canonical keys from synonyms in place
|
|
1373
|
-
// so a misnamed required param no longer yields /pages/undefined or an
|
|
1374
|
-
// empty "no response received" upload. No-op for tools without aliases;
|
|
1375
|
-
// an explicit canonical value is never overwritten.
|
|
1376
|
-
normalizeToolArgs(canonical, args);
|
|
1377
1300
|
if (canonical === 'wordpress_redeem_token') {
|
|
1378
1301
|
// redeemInstallToken returns a plain object. Wrap it in the standard
|
|
1379
1302
|
// MCP content envelope — the early-exit path bypasses the normal
|
|
@@ -1475,18 +1398,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
1475
1398
|
timeout_ms: error.timeoutMs,
|
|
1476
1399
|
message: error.message,
|
|
1477
1400
|
hint: 'Other tools should continue to work. ' +
|
|
1478
|
-
// For a read on a large page, the timeout is almost
|
|
1479
|
-
// always a SIZE problem wearing a timeout costume: a big
|
|
1480
|
-
// page takes longer to generate/transfer than the
|
|
1481
|
-
// per-request HTTP timeout, so the request aborts before
|
|
1482
|
-
// the response-size cap can return its truncation
|
|
1483
|
-
// envelope. Without this line the user only sees "timeout"
|
|
1484
|
-
// and keeps raising RESPIRA_MAX_TOOL_TIMEOUT_MS or
|
|
1485
|
-
// restarting the client, never learning it was the cap.
|
|
1486
|
-
// (Jeff Imperial, 2026-07-09.)
|
|
1487
|
-
(/read_page|read_post|read_custom_post|extract_builder_content|find_builder_targets|get_builder_info|get_page/.test(String(error.toolName || ''))
|
|
1488
|
-
? 'This was a read on a page or site that may be large. The most likely cause is response SIZE, not a dead server: a big page can exceed the per-request HTTP timeout before the response-size cap returns a truncation envelope, so it surfaces as a plain timeout. Recover by calling respira_get_page_outline first, then respira_extract_builder_content with a node_id to pull one row or subtree at a time (or add a search / per_page filter). '
|
|
1489
|
-
: '') +
|
|
1490
1401
|
'If this operation genuinely needs more time, raise RESPIRA_MAX_TOOL_TIMEOUT_MS. ' +
|
|
1491
1402
|
'For uploads specifically, also see RESPIRA_UPLOAD_TIMEOUT_MS and RESPIRA_MAX_UPLOAD_MB. ' +
|
|
1492
1403
|
'To triangulate which network layer (origin / Cloudflare / Wordfence) is slow, run respira_diagnose_connection — it probes the same site with HEAD/GET/OPTIONS and reports per-layer latency + edge-layer presence. ' +
|
|
@@ -2284,7 +2195,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
2284
2195
|
},
|
|
2285
2196
|
{
|
|
2286
2197
|
name: 'wordpress_inject_builder_content',
|
|
2287
|
-
description: 'REPLACE (or append to) the entire page builder layout. WARNING: By default this REPLACES all existing content (use mode:"append" to add content without destroying existing elements). For editing a single module, use wordpress_update_module instead. Use exactly: gutenberg, divi, elementor, bricks, beaver, oxygen, breakdance, brizy, thrive, visual-composer, wpbakery. For Divi, divi_version is required ("4" or "5"). Replacing a page that already has content with mode="replace" requires confirm_replace=true, else returns 409 respira_replace_confirmation_required. Alternatives: mode="append" to add to existing content, or edit_target="live" to overwrite the live page directly. The gate prevents silent data loss.\n\nCanonical Divi 5 nested shape: `content: [{ type: "section", children: [{ type: "row", children: [{ type: "column", children: [{ type: "heading", settings: {...} }] }] }] }]` (rows/cols/columns/modules/elements/innerBlocks also accepted as `children` aliases). Each Divi module needs its render-critical attr in `settings` (e.g. heading→title, text→content, button→button_text); the plugin names any missing one via `respira_divi_required_attrs_missing
|
|
2198
|
+
description: 'REPLACE (or append to) the entire page builder layout. WARNING: By default this REPLACES all existing content (use mode:"append" to add content without destroying existing elements). For editing a single module, use wordpress_update_module instead. Use exactly: gutenberg, divi, elementor, bricks, beaver, oxygen, breakdance, brizy, thrive, visual-composer, wpbakery. For Divi, divi_version is required ("4" or "5"). Replacing a page that already has content with mode="replace" requires confirm_replace=true, else returns 409 respira_replace_confirmation_required. Alternatives: mode="append" to add to existing content, or edit_target="live" to overwrite the live page directly. The gate prevents silent data loss.\n\nCanonical Divi 5 nested shape: `content: [{ type: "section", children: [{ type: "row", children: [{ type: "column", children: [{ type: "heading", settings: {...} }] }] }] }]` (rows/cols/columns/modules/elements/innerBlocks also accepted as `children` aliases). Each Divi module needs its render-critical attr in `settings` (e.g. heading→title, text→content, button→button_text); the plugin names any missing one via `respira_divi_required_attrs_missing`.',
|
|
2288
2199
|
inputSchema: {
|
|
2289
2200
|
type: 'object',
|
|
2290
2201
|
properties: {
|
|
@@ -2319,14 +2230,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
2319
2230
|
description: 'Divi generation hint ("4" for shortcode format, "5" for block format). Required for all Divi inject calls.',
|
|
2320
2231
|
enum: ['4', '5'],
|
|
2321
2232
|
},
|
|
2322
|
-
target_path: {
|
|
2323
|
-
type: 'string',
|
|
2324
|
-
description: 'Insert the content as children of the container at this bracket path (from extract_builder_content), e.g. "sections[0].rows[1].columns[0]", instead of at page top level. Use this to build a large section in small calls when the whole tree exceeds the AI client payload cap: create the section frame first, then append each module by target_path. Omit for normal whole-page inject. Supported on Divi 5 today; other builders return respira_target_path_unsupported.',
|
|
2325
|
-
},
|
|
2326
|
-
position: {
|
|
2327
|
-
type: 'number',
|
|
2328
|
-
description: 'Where to insert within the target_path container: -1 (default) appends to the end, 0 inserts first, N inserts at index N. Only used when target_path is set.',
|
|
2329
|
-
},
|
|
2330
2233
|
},
|
|
2331
2234
|
required: ['builder', 'page_id', 'content'],
|
|
2332
2235
|
},
|
|
@@ -2409,10 +2312,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
2409
2312
|
description: 'When editing an original, choose ask, live, or duplicate. Defaults to ask when direct editing is enabled.',
|
|
2410
2313
|
enum: ['ask', 'live', 'duplicate'],
|
|
2411
2314
|
},
|
|
2412
|
-
confirm_live_edit: {
|
|
2413
|
-
type: 'boolean',
|
|
2414
|
-
description: 'Set true together with edit_target:"live" to acknowledge a direct write to the published original. Exposed at the top level so autonomous flows can complete the confirmation_required handshake without polluting the updates object.',
|
|
2415
|
-
},
|
|
2416
2315
|
},
|
|
2417
2316
|
required: ['builder', 'page_id', 'module_identifier', 'updates'],
|
|
2418
2317
|
},
|
|
@@ -2573,10 +2472,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
2573
2472
|
description: 'When editing an original, choose ask, live, or duplicate. Defaults to ask when direct editing is enabled.',
|
|
2574
2473
|
enum: ['ask', 'live', 'duplicate'],
|
|
2575
2474
|
},
|
|
2576
|
-
confirm_live_edit: {
|
|
2577
|
-
type: 'boolean',
|
|
2578
|
-
description: 'Set true together with edit_target:"live" to acknowledge a direct write to the published original. Exposed at the top level so autonomous flows can complete the confirmation_required handshake without polluting the operations.',
|
|
2579
|
-
},
|
|
2580
2475
|
},
|
|
2581
2476
|
required: ['builder', 'post_id', 'operations'],
|
|
2582
2477
|
},
|
|
@@ -2647,35 +2542,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
2647
2542
|
required: ['relative_path', 'content'],
|
|
2648
2543
|
},
|
|
2649
2544
|
},
|
|
2650
|
-
{
|
|
2651
|
-
name: 'wordpress_read_debug_log',
|
|
2652
|
-
description: 'Tail wp-content/debug.log (or a custom WP_DEBUG_LOG path) to diagnose PHP fatals, warnings and notices without SSH. Read-only: never writes, rotates, or deletes the log. Only reads the last few MB of the file from the end, so this is safe on large logs. Requires plugin v7.4.21+, WP_DEBUG_LOG must be enabled on the site, and the user behind the API key must have the WP `edit_themes` capability (same gate as wordpress_read_theme_file). Lines are grouped into log entries (a stack trace stays attached to its originating error) and obvious secrets (Authorization: Bearer tokens, api_key=/password= style params, long hex/base64 blobs) are redacted before the response leaves the server. Use `level` to filter to fatals/errors/warnings, `since` to scope to recent activity, and `grep` to search for a specific string or pattern. Pairs well with wordpress_diagnose_connection: when a route returns a 5xx, pull the matching debug.log lines from around that time.',
|
|
2653
|
-
inputSchema: {
|
|
2654
|
-
type: 'object',
|
|
2655
|
-
properties: {
|
|
2656
|
-
lines: {
|
|
2657
|
-
type: 'number',
|
|
2658
|
-
description: 'Maximum number of matching log entries to return, most recent first is NOT applied (returned in chronological order). Default 200, capped at 2000.',
|
|
2659
|
-
minimum: 1,
|
|
2660
|
-
maximum: 2000,
|
|
2661
|
-
},
|
|
2662
|
-
level: {
|
|
2663
|
-
type: 'string',
|
|
2664
|
-
description: 'Filter by severity: "fatal" (PHP Fatal error only), "error" (fatal + parse + recoverable + warning-class PHP errors), "warning" (PHP Warning / Notice / Deprecated), or "all" (default, no filtering).',
|
|
2665
|
-
enum: ['fatal', 'error', 'warning', 'all'],
|
|
2666
|
-
},
|
|
2667
|
-
since: {
|
|
2668
|
-
type: 'string',
|
|
2669
|
-
description: 'Only return entries whose leading timestamp is after this point. Accepts an ISO-8601 timestamp or a relative expression like "-1 hour", "-30 minutes", "yesterday".',
|
|
2670
|
-
},
|
|
2671
|
-
grep: {
|
|
2672
|
-
type: 'string',
|
|
2673
|
-
description: 'Filter to entries containing this plain substring (case-insensitive), or a `/pattern/` regex (also matched case-insensitively; malformed patterns fall back to a plain substring match instead of erroring).',
|
|
2674
|
-
},
|
|
2675
|
-
},
|
|
2676
|
-
},
|
|
2677
|
-
readOnlyHint: true,
|
|
2678
|
-
},
|
|
2679
2545
|
{
|
|
2680
2546
|
name: 'wordpress_switch_site',
|
|
2681
2547
|
description: 'Switch to a different WordPress site in the active Respira multi-site configuration.',
|
|
@@ -4061,21 +3927,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
4061
3927
|
},
|
|
4062
3928
|
destructiveHint: true,
|
|
4063
3929
|
},
|
|
4064
|
-
{
|
|
4065
|
-
name: 'wordpress_detach_duplicate',
|
|
4066
|
-
description: 'Clear the is_duplicate/original_id link on a post Respira still tracks as a pending duplicate, WITHOUT deleting it or overwriting the original. Use this instead of approving or rejecting when the "duplicate" turned out to be its own independent live page (for example it was cloned from the original once, long ago, as a starting template and later rebuilt into unrelated content). This is the fix for "Cannot edit original ... A duplicate already exists ..." errors caused by a stale/phantom duplicate: detach the duplicate_id from the error response, then retry the original edit. Works for pages, posts, and any custom post type since the relationship is tracked by post meta, not post type.',
|
|
4067
|
-
inputSchema: {
|
|
4068
|
-
type: 'object',
|
|
4069
|
-
properties: {
|
|
4070
|
-
duplicate_id: {
|
|
4071
|
-
type: 'number',
|
|
4072
|
-
description: 'ID of the post currently marked as a Respira duplicate to detach (the duplicate_id returned in the "A duplicate already exists" error, NOT the original id).',
|
|
4073
|
-
},
|
|
4074
|
-
},
|
|
4075
|
-
required: ['duplicate_id'],
|
|
4076
|
-
},
|
|
4077
|
-
idempotentHint: true,
|
|
4078
|
-
},
|
|
4079
3930
|
// Options
|
|
4080
3931
|
{
|
|
4081
3932
|
name: 'wordpress_list_options',
|
|
@@ -4528,56 +4379,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
4528
4379
|
/** Cached site context for tool filtering (refreshed on site switch). */
|
|
4529
4380
|
cachedFilterContext = null;
|
|
4530
4381
|
static FILTER_CACHE_TTL = 60_000; // 1 minute
|
|
4531
|
-
/**
|
|
4532
|
-
* Capability negotiation (7.2.6): tools whose plugin REST route only exists
|
|
4533
|
-
* from a given plugin version. On older plugins these 404 with
|
|
4534
|
-
* respira_rest_no_route — the biggest non-element failure class in the
|
|
4535
|
-
* MCP-quality telemetry (656 errors across 97 tools), pure version skew since
|
|
4536
|
-
* only ~half the connected sites run a current plugin. We stop advertising
|
|
4537
|
-
* these tools when the connected plugin predates the route, so the agent is
|
|
4538
|
-
* never offered something that will 404. @since values are verified against
|
|
4539
|
-
* the plugin source (the first release tag that contains each route).
|
|
4540
|
-
*/
|
|
4541
|
-
static TOOL_MIN_PLUGIN_VERSION = {
|
|
4542
|
-
wordpress_run_pagespeed_audit: '7.1.0', // /pagespeed/audit — first in v7.1.0
|
|
4543
|
-
wordpress_analyze_pagespeed: '7.1.0', // /analyze/pagespeed — first in v7.1.0
|
|
4544
|
-
wordpress_abilities_gap_report: '7.1.0', // /abilities/gap-report — first in v7.1.0
|
|
4545
|
-
wordpress_invoke_ability: '7.1.0', // /abilities/invoke — first in v7.1.0
|
|
4546
|
-
wordpress_read_theme_file: '7.0.42', // /theme-files/* — @since 7.0.42
|
|
4547
|
-
wordpress_write_theme_file: '7.0.42',
|
|
4548
|
-
wordpress_append_theme_file: '7.0.42',
|
|
4549
|
-
};
|
|
4550
|
-
/**
|
|
4551
|
-
* Capability negotiation Phase 2 (7.2.7): the feature flag each version-skewed
|
|
4552
|
-
* tool needs. From plugin 7.4.20 the compatibility endpoint declares these
|
|
4553
|
-
* flags from its LIVE registered REST routes, so this is an exact "does the
|
|
4554
|
-
* route exist on this install" signal — more precise than the version map
|
|
4555
|
-
* above and self-maintaining. The version map stays as the fallback for
|
|
4556
|
-
* plugins older than 7.4.20, which do not declare per-capability features yet.
|
|
4557
|
-
*/
|
|
4558
|
-
static TOOL_REQUIRED_FEATURE = {
|
|
4559
|
-
wordpress_run_pagespeed_audit: 'pagespeed',
|
|
4560
|
-
wordpress_analyze_pagespeed: 'pagespeed',
|
|
4561
|
-
wordpress_abilities_gap_report: 'abilities',
|
|
4562
|
-
wordpress_invoke_ability: 'abilities',
|
|
4563
|
-
wordpress_read_theme_file: 'theme_files',
|
|
4564
|
-
wordpress_write_theme_file: 'theme_files',
|
|
4565
|
-
wordpress_append_theme_file: 'theme_files',
|
|
4566
|
-
};
|
|
4567
|
-
/** First plugin version that declares per-capability features (Phase 2). */
|
|
4568
|
-
static FEATURE_DECL_MIN_PLUGIN_VERSION = '7.4.20';
|
|
4569
|
-
/** True when version `a` is strictly older than `b` (both "x.y.z"). */
|
|
4570
|
-
static versionLt(a, b) {
|
|
4571
|
-
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
|
4572
|
-
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
|
4573
|
-
for (let i = 0; i < 3; i++) {
|
|
4574
|
-
const x = pa[i] || 0;
|
|
4575
|
-
const y = pb[i] || 0;
|
|
4576
|
-
if (x !== y)
|
|
4577
|
-
return x < y;
|
|
4578
|
-
}
|
|
4579
|
-
return false;
|
|
4580
|
-
}
|
|
4581
4382
|
async filterToolsByContext(tools) {
|
|
4582
4383
|
if (!this.currentSite) {
|
|
4583
4384
|
return tools; // No site connected — show everything.
|
|
@@ -4596,22 +4397,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
4596
4397
|
return tools; // Can't fetch context — show everything.
|
|
4597
4398
|
}
|
|
4598
4399
|
}
|
|
4599
|
-
// Capability negotiation: make sure the plugin version is known (cheap and
|
|
4600
|
-
// memoized in the client) so the per-tool gate below can hide routes the
|
|
4601
|
-
// connected plugin is too old to have. Never blocks the filter on failure.
|
|
4602
|
-
try {
|
|
4603
|
-
await this.currentSite.checkCompatibility();
|
|
4604
|
-
}
|
|
4605
|
-
catch {
|
|
4606
|
-
/* non-fatal — fall through with whatever version we have (possibly null) */
|
|
4607
|
-
}
|
|
4608
|
-
const pluginVersion = this.currentSite.pluginVersion ?? null;
|
|
4609
|
-
// Phase 2 (7.2.7): plugin 7.4.20+ declares per-capability features from its
|
|
4610
|
-
// live REST routes; trust those exactly. Older plugins do not, so they fall
|
|
4611
|
-
// back to the version map in the per-tool gate below.
|
|
4612
|
-
const pluginFeatures = this.currentSite.pluginFeatures ?? [];
|
|
4613
|
-
const declaresFeatures = !!pluginVersion &&
|
|
4614
|
-
!RespiraWordPressServer.versionLt(pluginVersion, RespiraWordPressServer.FEATURE_DECL_MIN_PLUGIN_VERSION);
|
|
4615
4400
|
const detectedBuilder = (context?.page_builder?.name || '').toLowerCase();
|
|
4616
4401
|
const hasWooCommerce = Boolean(context?.woocommerce?.active || context?.addons?.woocommerce?.installed);
|
|
4617
4402
|
return tools.filter((tool) => {
|
|
@@ -4636,20 +4421,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
4636
4421
|
if (name.startsWith('woocommerce_')) {
|
|
4637
4422
|
return hasWooCommerce;
|
|
4638
4423
|
}
|
|
4639
|
-
// Capability negotiation: hide tools whose plugin route the connected site
|
|
4640
|
-
// does not expose (avoids rest_no_route 404s on version-skewed sites).
|
|
4641
|
-
// Prefer the plugin's declared per-capability features (7.4.20+, computed
|
|
4642
|
-
// from its live routes — exact), and fall back to the verified version map
|
|
4643
|
-
// for older plugins that do not declare them. Both are conservative: a tool
|
|
4644
|
-
// is hidden only on a positive signal that its route is absent.
|
|
4645
|
-
const reqFeature = RespiraWordPressServer.TOOL_REQUIRED_FEATURE[name];
|
|
4646
|
-
if (reqFeature && declaresFeatures) {
|
|
4647
|
-
return pluginFeatures.includes(reqFeature);
|
|
4648
|
-
}
|
|
4649
|
-
const minPv = RespiraWordPressServer.TOOL_MIN_PLUGIN_VERSION[name];
|
|
4650
|
-
if (minPv && pluginVersion && RespiraWordPressServer.versionLt(pluginVersion, minPv)) {
|
|
4651
|
-
return false;
|
|
4652
|
-
}
|
|
4653
4424
|
// Everything else: always show.
|
|
4654
4425
|
return true;
|
|
4655
4426
|
});
|
|
@@ -4767,6 +4538,15 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
4767
4538
|
items: { type: 'object' },
|
|
4768
4539
|
description: 'Alternative tag payload (IDs, names, slugs, or objects with id/name/slug)',
|
|
4769
4540
|
},
|
|
4541
|
+
global_unique_id: {
|
|
4542
|
+
type: 'string',
|
|
4543
|
+
description: 'GTIN/UPC/EAN (WooCommerce 9.2+). The strongest identifier for feeds and AI shopping.',
|
|
4544
|
+
},
|
|
4545
|
+
gallery_image_ids: {
|
|
4546
|
+
type: 'array',
|
|
4547
|
+
items: { type: 'number' },
|
|
4548
|
+
description: 'Gallery image attachment IDs; empty array clears the gallery',
|
|
4549
|
+
},
|
|
4770
4550
|
},
|
|
4771
4551
|
required: ['id'],
|
|
4772
4552
|
},
|
|
@@ -5487,6 +5267,320 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
5487
5267
|
},
|
|
5488
5268
|
destructiveHint: true,
|
|
5489
5269
|
},
|
|
5270
|
+
// --- Subscriptions / Bookings / Memberships (addon v3.1, extensions) ---
|
|
5271
|
+
{
|
|
5272
|
+
name: 'woocommerce_list_subscriptions',
|
|
5273
|
+
description: 'List WooCommerce Subscriptions, filterable by status, customer, or product. Requires the WooCommerce Subscriptions extension on the site.',
|
|
5274
|
+
inputSchema: {
|
|
5275
|
+
type: 'object',
|
|
5276
|
+
properties: {
|
|
5277
|
+
status: { type: 'string', description: 'active, on-hold, cancelled, pending-cancel, expired' },
|
|
5278
|
+
customer_id: { type: 'number' },
|
|
5279
|
+
product_id: { type: 'number' },
|
|
5280
|
+
per_page: { type: 'number' },
|
|
5281
|
+
page: { type: 'number' },
|
|
5282
|
+
},
|
|
5283
|
+
},
|
|
5284
|
+
readOnlyHint: true,
|
|
5285
|
+
},
|
|
5286
|
+
{
|
|
5287
|
+
name: 'woocommerce_get_subscription',
|
|
5288
|
+
description: 'Get one subscription with items, dates, and payment method.',
|
|
5289
|
+
inputSchema: {
|
|
5290
|
+
type: 'object',
|
|
5291
|
+
properties: {
|
|
5292
|
+
id: { type: 'number', description: 'Subscription ID' },
|
|
5293
|
+
},
|
|
5294
|
+
required: ['id'],
|
|
5295
|
+
},
|
|
5296
|
+
readOnlyHint: true,
|
|
5297
|
+
},
|
|
5298
|
+
{
|
|
5299
|
+
name: 'woocommerce_update_subscription_status',
|
|
5300
|
+
description: 'Pause (on-hold), reactivate, or cancel a subscription. DRY-RUN BY DEFAULT: pass dry_run: false to apply. Transitions are validated against Subscriptions rules; snapshots taken.',
|
|
5301
|
+
inputSchema: {
|
|
5302
|
+
type: 'object',
|
|
5303
|
+
properties: {
|
|
5304
|
+
id: { type: 'number', description: 'Subscription ID' },
|
|
5305
|
+
status: { type: 'string', description: 'active, on-hold, cancelled, pending-cancel' },
|
|
5306
|
+
note: { type: 'string' },
|
|
5307
|
+
dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
|
|
5308
|
+
},
|
|
5309
|
+
required: ['id', 'status'],
|
|
5310
|
+
},
|
|
5311
|
+
destructiveHint: true,
|
|
5312
|
+
},
|
|
5313
|
+
{
|
|
5314
|
+
name: 'woocommerce_update_subscription_dates',
|
|
5315
|
+
description: 'Move a subscription\'s next_payment, end, or trial_end dates (empty string clears). DRY-RUN BY DEFAULT: moving next_payment changes when the customer is charged.',
|
|
5316
|
+
inputSchema: {
|
|
5317
|
+
type: 'object',
|
|
5318
|
+
properties: {
|
|
5319
|
+
id: { type: 'number', description: 'Subscription ID' },
|
|
5320
|
+
next_payment: { type: 'string', description: 'ISO 8601' },
|
|
5321
|
+
end: { type: 'string' },
|
|
5322
|
+
trial_end: { type: 'string' },
|
|
5323
|
+
dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
|
|
5324
|
+
},
|
|
5325
|
+
required: ['id'],
|
|
5326
|
+
},
|
|
5327
|
+
destructiveHint: true,
|
|
5328
|
+
},
|
|
5329
|
+
{
|
|
5330
|
+
name: 'woocommerce_add_subscription_note',
|
|
5331
|
+
description: 'Add a note to a subscription. Private by default; customer_note: true emails the customer.',
|
|
5332
|
+
inputSchema: {
|
|
5333
|
+
type: 'object',
|
|
5334
|
+
properties: {
|
|
5335
|
+
id: { type: 'number', description: 'Subscription ID' },
|
|
5336
|
+
note: { type: 'string' },
|
|
5337
|
+
customer_note: { type: 'boolean', description: 'Default false (private note)' },
|
|
5338
|
+
},
|
|
5339
|
+
required: ['id', 'note'],
|
|
5340
|
+
},
|
|
5341
|
+
},
|
|
5342
|
+
{
|
|
5343
|
+
name: 'woocommerce_list_bookings',
|
|
5344
|
+
description: 'List WooCommerce Bookings, filterable by status or product. Requires the WooCommerce Bookings extension on the site.',
|
|
5345
|
+
inputSchema: {
|
|
5346
|
+
type: 'object',
|
|
5347
|
+
properties: {
|
|
5348
|
+
status: { type: 'string', description: 'unpaid, pending-confirmation, confirmed, paid, cancelled, complete' },
|
|
5349
|
+
product_id: { type: 'number' },
|
|
5350
|
+
per_page: { type: 'number' },
|
|
5351
|
+
page: { type: 'number' },
|
|
5352
|
+
},
|
|
5353
|
+
},
|
|
5354
|
+
readOnlyHint: true,
|
|
5355
|
+
},
|
|
5356
|
+
{
|
|
5357
|
+
name: 'woocommerce_get_booking',
|
|
5358
|
+
description: 'Get one booking with times, persons, resource, and order linkage.',
|
|
5359
|
+
inputSchema: {
|
|
5360
|
+
type: 'object',
|
|
5361
|
+
properties: {
|
|
5362
|
+
id: { type: 'number', description: 'Booking ID' },
|
|
5363
|
+
},
|
|
5364
|
+
required: ['id'],
|
|
5365
|
+
},
|
|
5366
|
+
readOnlyHint: true,
|
|
5367
|
+
},
|
|
5368
|
+
{
|
|
5369
|
+
name: 'woocommerce_update_booking_status',
|
|
5370
|
+
description: 'Confirm, mark paid, complete, or cancel a booking. DRY-RUN BY DEFAULT: status changes can trigger customer emails.',
|
|
5371
|
+
inputSchema: {
|
|
5372
|
+
type: 'object',
|
|
5373
|
+
properties: {
|
|
5374
|
+
id: { type: 'number', description: 'Booking ID' },
|
|
5375
|
+
status: { type: 'string', description: 'unpaid, pending-confirmation, confirmed, paid, cancelled, complete' },
|
|
5376
|
+
dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
|
|
5377
|
+
},
|
|
5378
|
+
required: ['id', 'status'],
|
|
5379
|
+
},
|
|
5380
|
+
destructiveHint: true,
|
|
5381
|
+
},
|
|
5382
|
+
{
|
|
5383
|
+
name: 'woocommerce_reschedule_booking',
|
|
5384
|
+
description: 'Move a booking to a new time window. DRY-RUN BY DEFAULT with a conflict scan against other bookings on the same product; overlapping windows need force: true.',
|
|
5385
|
+
inputSchema: {
|
|
5386
|
+
type: 'object',
|
|
5387
|
+
properties: {
|
|
5388
|
+
id: { type: 'number', description: 'Booking ID' },
|
|
5389
|
+
start: { type: 'string', description: 'ISO 8601' },
|
|
5390
|
+
end: { type: 'string', description: 'ISO 8601' },
|
|
5391
|
+
force: { type: 'boolean', description: 'Apply despite conflicts' },
|
|
5392
|
+
dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
|
|
5393
|
+
},
|
|
5394
|
+
required: ['id', 'start', 'end'],
|
|
5395
|
+
},
|
|
5396
|
+
destructiveHint: true,
|
|
5397
|
+
},
|
|
5398
|
+
{
|
|
5399
|
+
name: 'woocommerce_list_membership_plans',
|
|
5400
|
+
description: 'All membership plans with member counts. Requires the WooCommerce Memberships extension on the site.',
|
|
5401
|
+
inputSchema: {
|
|
5402
|
+
type: 'object',
|
|
5403
|
+
properties: {},
|
|
5404
|
+
},
|
|
5405
|
+
readOnlyHint: true,
|
|
5406
|
+
},
|
|
5407
|
+
{
|
|
5408
|
+
name: 'woocommerce_list_memberships',
|
|
5409
|
+
description: 'List user memberships, filterable by plan, customer, or status.',
|
|
5410
|
+
inputSchema: {
|
|
5411
|
+
type: 'object',
|
|
5412
|
+
properties: {
|
|
5413
|
+
plan_id: { type: 'number' },
|
|
5414
|
+
customer_id: { type: 'number' },
|
|
5415
|
+
status: { type: 'string', description: 'active, paused, cancelled, expired, pending' },
|
|
5416
|
+
per_page: { type: 'number' },
|
|
5417
|
+
page: { type: 'number' },
|
|
5418
|
+
},
|
|
5419
|
+
},
|
|
5420
|
+
readOnlyHint: true,
|
|
5421
|
+
},
|
|
5422
|
+
{
|
|
5423
|
+
name: 'woocommerce_get_membership',
|
|
5424
|
+
description: 'Get one user membership with plan, dates, and order linkage.',
|
|
5425
|
+
inputSchema: {
|
|
5426
|
+
type: 'object',
|
|
5427
|
+
properties: {
|
|
5428
|
+
id: { type: 'number', description: 'Membership ID' },
|
|
5429
|
+
},
|
|
5430
|
+
required: ['id'],
|
|
5431
|
+
},
|
|
5432
|
+
readOnlyHint: true,
|
|
5433
|
+
},
|
|
5434
|
+
{
|
|
5435
|
+
name: 'woocommerce_update_membership_status',
|
|
5436
|
+
description: 'Pause, resume, or cancel a member\'s access. DRY-RUN BY DEFAULT: the change takes effect immediately for the member.',
|
|
5437
|
+
inputSchema: {
|
|
5438
|
+
type: 'object',
|
|
5439
|
+
properties: {
|
|
5440
|
+
id: { type: 'number', description: 'Membership ID' },
|
|
5441
|
+
membership_action: { type: 'string', description: 'pause, resume, or cancel' },
|
|
5442
|
+
note: { type: 'string' },
|
|
5443
|
+
dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
|
|
5444
|
+
},
|
|
5445
|
+
required: ['id', 'membership_action'],
|
|
5446
|
+
},
|
|
5447
|
+
destructiveHint: true,
|
|
5448
|
+
},
|
|
5449
|
+
{
|
|
5450
|
+
name: 'woocommerce_set_membership_end_date',
|
|
5451
|
+
description: 'Move or clear a membership\'s expiry date (empty string = never expires). DRY-RUN BY DEFAULT.',
|
|
5452
|
+
inputSchema: {
|
|
5453
|
+
type: 'object',
|
|
5454
|
+
properties: {
|
|
5455
|
+
id: { type: 'number', description: 'Membership ID' },
|
|
5456
|
+
end_date: { type: 'string', description: 'ISO 8601; empty string = never expires' },
|
|
5457
|
+
dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
|
|
5458
|
+
},
|
|
5459
|
+
required: ['id', 'end_date'],
|
|
5460
|
+
},
|
|
5461
|
+
destructiveHint: true,
|
|
5462
|
+
},
|
|
5463
|
+
// --- Assisted checkout (addon v3.1) ---
|
|
5464
|
+
{
|
|
5465
|
+
name: 'woocommerce_create_cart_link',
|
|
5466
|
+
description: 'Create a signed multi-item cart URL for a shopper: their cart fills server-side and they land on this store\'s own checkout, with the order attributed to the agent source. The assisted-checkout layer that works while native agent checkout programs are gated.',
|
|
5467
|
+
inputSchema: {
|
|
5468
|
+
type: 'object',
|
|
5469
|
+
properties: {
|
|
5470
|
+
items: {
|
|
5471
|
+
type: 'array',
|
|
5472
|
+
items: { type: 'object' },
|
|
5473
|
+
description: 'Each: {product_id or variation_id, quantity}. Variable products need a variation_id.',
|
|
5474
|
+
},
|
|
5475
|
+
utm_source: { type: 'string', description: 'Attribution source, e.g. chatgpt.com (default ai-agent)' },
|
|
5476
|
+
replace_cart: { type: 'boolean', description: 'Empty the shopper\'s cart first (default false)' },
|
|
5477
|
+
expires_in_days: { type: 'number', description: 'Default 30; 0 = never expires' },
|
|
5478
|
+
},
|
|
5479
|
+
required: ['items'],
|
|
5480
|
+
},
|
|
5481
|
+
},
|
|
5482
|
+
{
|
|
5483
|
+
name: 'woocommerce_agent_orders_report',
|
|
5484
|
+
description: 'Orders and revenue that arrived through agent cart links over a period, with a per-source breakdown.',
|
|
5485
|
+
inputSchema: {
|
|
5486
|
+
type: 'object',
|
|
5487
|
+
properties: {
|
|
5488
|
+
days: { type: 'number', description: 'Period in days (default 30, max 365)' },
|
|
5489
|
+
},
|
|
5490
|
+
},
|
|
5491
|
+
readOnlyHint: true,
|
|
5492
|
+
},
|
|
5493
|
+
// --- Readiness autofix (addon v3.1) ---
|
|
5494
|
+
{
|
|
5495
|
+
name: 'woocommerce_readiness_fixlist',
|
|
5496
|
+
description: 'The catalog AI-readiness scan as an ordered work queue: weakest products first, each failing check paired with the tool and params that fix it. Work it top-down to raise the catalog score.',
|
|
5497
|
+
inputSchema: {
|
|
5498
|
+
type: 'object',
|
|
5499
|
+
properties: {
|
|
5500
|
+
limit: { type: 'number', description: 'Products to scan (default 100, max 500)' },
|
|
5501
|
+
target_score: { type: 'number', description: 'Skip products at or above this score (default 90)' },
|
|
5502
|
+
},
|
|
5503
|
+
},
|
|
5504
|
+
readOnlyHint: true,
|
|
5505
|
+
},
|
|
5506
|
+
{
|
|
5507
|
+
name: 'woocommerce_set_image_alt',
|
|
5508
|
+
description: 'Set alt text on a product\'s featured image (default) or a specific gallery image. The readiness check that fails most often.',
|
|
5509
|
+
inputSchema: {
|
|
5510
|
+
type: 'object',
|
|
5511
|
+
properties: {
|
|
5512
|
+
id: { type: 'number', description: 'Product ID' },
|
|
5513
|
+
alt: { type: 'string' },
|
|
5514
|
+
attachment_id: { type: 'number', description: 'Optional gallery image; defaults to the featured image' },
|
|
5515
|
+
},
|
|
5516
|
+
required: ['id', 'alt'],
|
|
5517
|
+
},
|
|
5518
|
+
idempotentHint: true,
|
|
5519
|
+
},
|
|
5520
|
+
// --- Product feeds + llms.txt (addon v3.1) ---
|
|
5521
|
+
{
|
|
5522
|
+
name: 'woocommerce_configure_feed',
|
|
5523
|
+
description: 'Read or update product feed settings (formats, cadence, countries, llms.txt) and get the public feed URLs. Call with no params to read.',
|
|
5524
|
+
inputSchema: {
|
|
5525
|
+
type: 'object',
|
|
5526
|
+
properties: {
|
|
5527
|
+
enabled_formats: {
|
|
5528
|
+
type: 'array',
|
|
5529
|
+
items: { type: 'string' },
|
|
5530
|
+
description: 'Any of: google, openai, meta, pinterest, tiktok, csv',
|
|
5531
|
+
},
|
|
5532
|
+
cadence: { type: 'string', description: 'hourly, twicedaily, or daily' },
|
|
5533
|
+
include_out_of_stock: { type: 'boolean' },
|
|
5534
|
+
target_countries: { type: 'array', items: { type: 'string' }, description: 'ISO 3166-1 alpha-2' },
|
|
5535
|
+
store_country: { type: 'string' },
|
|
5536
|
+
llms_txt: { type: 'boolean', description: 'Serve a store llms.txt at the site root' },
|
|
5537
|
+
rotate_token: { type: 'boolean', description: 'Rotate the secret feed URLs' },
|
|
5538
|
+
},
|
|
5539
|
+
},
|
|
5540
|
+
idempotentHint: true,
|
|
5541
|
+
},
|
|
5542
|
+
{
|
|
5543
|
+
name: 'woocommerce_generate_feed',
|
|
5544
|
+
description: 'Build the enabled product feeds now (batched in the background; the first batch runs immediately, so this works even when wp-cron is dead). Idempotent while a build is running.',
|
|
5545
|
+
inputSchema: {
|
|
5546
|
+
type: 'object',
|
|
5547
|
+
properties: {
|
|
5548
|
+
format: { type: 'string', description: 'Optional: enable and build one specific format' },
|
|
5549
|
+
},
|
|
5550
|
+
},
|
|
5551
|
+
},
|
|
5552
|
+
{
|
|
5553
|
+
name: 'woocommerce_get_feed_status',
|
|
5554
|
+
description: 'Per-format feed URLs, row counts, generation times, running build progress, and wp-cron health warnings.',
|
|
5555
|
+
inputSchema: {
|
|
5556
|
+
type: 'object',
|
|
5557
|
+
properties: {},
|
|
5558
|
+
},
|
|
5559
|
+
readOnlyHint: true,
|
|
5560
|
+
},
|
|
5561
|
+
{
|
|
5562
|
+
name: 'woocommerce_validate_feed',
|
|
5563
|
+
description: 'Check a catalog sample against one feed format\'s requirements (identifiers, images, descriptions, titles, category mapping) with per-check failing product IDs, so the catalog tools can fix them.',
|
|
5564
|
+
inputSchema: {
|
|
5565
|
+
type: 'object',
|
|
5566
|
+
properties: {
|
|
5567
|
+
format: { type: 'string', description: 'google (default), openai, meta, pinterest, tiktok, csv' },
|
|
5568
|
+
limit: { type: 'number', description: 'Parent products to sample (default 100, max 200)' },
|
|
5569
|
+
},
|
|
5570
|
+
},
|
|
5571
|
+
readOnlyHint: true,
|
|
5572
|
+
},
|
|
5573
|
+
{
|
|
5574
|
+
name: 'woocommerce_set_feed_category_mapping',
|
|
5575
|
+
description: 'Map WooCommerce product categories to Google product categories for the feeds. Call with no params to list mapped and unmapped categories with product counts.',
|
|
5576
|
+
inputSchema: {
|
|
5577
|
+
type: 'object',
|
|
5578
|
+
properties: {
|
|
5579
|
+
mappings: { type: 'object', description: '{"<term_id>": "<google category id or full path>"}; empty string removes a mapping' },
|
|
5580
|
+
},
|
|
5581
|
+
},
|
|
5582
|
+
idempotentHint: true,
|
|
5583
|
+
},
|
|
5490
5584
|
// --- AI readiness (addon v3.0) ---
|
|
5491
5585
|
{
|
|
5492
5586
|
name: 'woocommerce_product_ai_readiness',
|
|
@@ -5755,10 +5849,10 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
5755
5849
|
? await client.getSiteContext()
|
|
5756
5850
|
: await client.getCompactSiteContext();
|
|
5757
5851
|
case 'wordpress_list_sites': {
|
|
5758
|
-
// Self-heal a
|
|
5759
|
-
//
|
|
5760
|
-
//
|
|
5761
|
-
|
|
5852
|
+
// Self-heal first so a site added on the dashboard after this server
|
|
5853
|
+
// started shows up here without a reinstall or re-paste (throttled,
|
|
5854
|
+
// best-effort, never throws).
|
|
5855
|
+
await this.maybeSelfHealSiteList();
|
|
5762
5856
|
const allSites = Array.from(this.sites.values());
|
|
5763
5857
|
const visibleSites = allSites.filter((site) => this.isSiteAllowed(site));
|
|
5764
5858
|
// v6.17.2: surface hidden sites so the AI can explain
|
|
@@ -5771,13 +5865,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
5771
5865
|
return {
|
|
5772
5866
|
sites: visibleSites.map((site) => this.getSiteSummary(site)),
|
|
5773
5867
|
active_site: this.getActiveSiteSummary(),
|
|
5774
|
-
...(refresh.added.length > 0
|
|
5775
|
-
? {
|
|
5776
|
-
refreshed_note: `Picked up ${refresh.added.length} newly added site${refresh.added.length === 1 ? '' : 's'} from your respira.press account: ${refresh.added.join(', ')}.${process.env.RESPIRA_CONFIG_B64?.trim()
|
|
5777
|
-
? ' This server is pinned to the RESPIRA_CONFIG_B64 env var, so update that env to keep these sites after a restart.'
|
|
5778
|
-
: ''}`,
|
|
5779
|
-
}
|
|
5780
|
-
: {}),
|
|
5781
5868
|
...(hiddenSites.length > 0
|
|
5782
5869
|
? {
|
|
5783
5870
|
hidden_by_filter: hiddenSites,
|
|
@@ -5919,14 +6006,14 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
5919
6006
|
return await client.getBuilderInlineSchemas(args.builder, args.types);
|
|
5920
6007
|
case 'wordpress_inject_builder_content':
|
|
5921
6008
|
return hoistDroppedStylingWarnings({
|
|
5922
|
-
...(await client.injectBuilderContent(args.builder, args.page_id, args.content, args.divi_version, args.edit_target, args.mode, args.confirm_replace
|
|
6009
|
+
...(await client.injectBuilderContent(args.builder, args.page_id, args.content, args.divi_version, args.edit_target, args.mode, args.confirm_replace)),
|
|
5923
6010
|
respira_approvals_url: client.getApprovalsUrl(),
|
|
5924
6011
|
});
|
|
5925
6012
|
case 'wordpress_make_responsive':
|
|
5926
6013
|
return await client.makeResponsive(args.page_id, args.dry_run || false);
|
|
5927
6014
|
case 'wordpress_update_module':
|
|
5928
6015
|
return hoistDroppedStylingWarnings({
|
|
5929
|
-
...(await client.updateModule(args.builder, args.page_id, args.module_identifier, args.updates, args.edit_target
|
|
6016
|
+
...(await client.updateModule(args.builder, args.page_id, args.module_identifier, args.updates, args.edit_target)),
|
|
5930
6017
|
respira_approvals_url: client.getApprovalsUrl(),
|
|
5931
6018
|
});
|
|
5932
6019
|
case 'wordpress_validate_security':
|
|
@@ -5943,17 +6030,17 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
5943
6030
|
...(await client.appendThemeFile(args.relative_path, args.content)),
|
|
5944
6031
|
respira_approvals_url: client.getApprovalsUrl(),
|
|
5945
6032
|
};
|
|
5946
|
-
case 'wordpress_read_debug_log':
|
|
5947
|
-
return await client.readDebugLog({
|
|
5948
|
-
lines: args.lines,
|
|
5949
|
-
level: args.level,
|
|
5950
|
-
since: args.since,
|
|
5951
|
-
grep: args.grep,
|
|
5952
|
-
});
|
|
5953
6033
|
case 'wordpress_switch_site': {
|
|
5954
|
-
|
|
6034
|
+
let newSite = this.sites.get(args.site_id);
|
|
6035
|
+
if (!newSite) {
|
|
6036
|
+
// The target may be a site added on the dashboard after this server
|
|
6037
|
+
// started. Self-heal once, then retry before giving up.
|
|
6038
|
+
await this.maybeSelfHealSiteList();
|
|
6039
|
+
newSite = this.sites.get(args.site_id);
|
|
6040
|
+
}
|
|
5955
6041
|
if (!newSite) {
|
|
5956
|
-
|
|
6042
|
+
const available = Array.from(this.sites.keys()).join(', ') || '(none configured)';
|
|
6043
|
+
throw new Error(`Site with ID "${args.site_id}" not found in configuration. Available: ${available}`);
|
|
5957
6044
|
}
|
|
5958
6045
|
if (!this.isSiteAllowed(newSite)) {
|
|
5959
6046
|
throw new Error(`Site "${args.site_id}" is not in this MCP configuration group.`);
|
|
@@ -6176,8 +6263,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6176
6263
|
confirm_live_edit: args.confirm_live_edit,
|
|
6177
6264
|
approval_token: args.approval_token,
|
|
6178
6265
|
});
|
|
6179
|
-
case 'wordpress_detach_duplicate':
|
|
6180
|
-
return await client.callRestV1('POST', `/duplicates/${args.duplicate_id}/detach`);
|
|
6181
6266
|
// Options
|
|
6182
6267
|
case 'wordpress_list_options':
|
|
6183
6268
|
return await client.listOptions(args.search);
|
|
@@ -6238,7 +6323,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6238
6323
|
case 'wordpress_end_session':
|
|
6239
6324
|
return await client.callRestV2('POST', '/snapshots/session/end', {});
|
|
6240
6325
|
case 'wordpress_apply_builder_patch':
|
|
6241
|
-
return await client.applyBuilderPatch(args.builder, args.post_id, args.operations, args.include, args.edit_target
|
|
6326
|
+
return await client.applyBuilderPatch(args.builder, args.post_id, args.operations, args.include, args.edit_target);
|
|
6242
6327
|
case 'woocommerce_list_products':
|
|
6243
6328
|
return await client.woocommerceListProducts(args);
|
|
6244
6329
|
case 'woocommerce_get_product':
|
|
@@ -6374,6 +6459,68 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6374
6459
|
const { id, ...payload } = args;
|
|
6375
6460
|
return await client.woocommerceCreateOrderRefund(id, payload);
|
|
6376
6461
|
}
|
|
6462
|
+
case 'woocommerce_list_subscriptions':
|
|
6463
|
+
return await client.woocommerceListSubscriptions(args);
|
|
6464
|
+
case 'woocommerce_get_subscription':
|
|
6465
|
+
return await client.woocommerceGetSubscription(args.id);
|
|
6466
|
+
case 'woocommerce_update_subscription_status': {
|
|
6467
|
+
const { id, ...payload } = args;
|
|
6468
|
+
return await client.woocommerceUpdateSubscriptionStatus(id, payload);
|
|
6469
|
+
}
|
|
6470
|
+
case 'woocommerce_update_subscription_dates': {
|
|
6471
|
+
const { id, ...payload } = args;
|
|
6472
|
+
return await client.woocommerceUpdateSubscriptionDates(id, payload);
|
|
6473
|
+
}
|
|
6474
|
+
case 'woocommerce_add_subscription_note': {
|
|
6475
|
+
const { id, ...payload } = args;
|
|
6476
|
+
return await client.woocommerceAddSubscriptionNote(id, payload);
|
|
6477
|
+
}
|
|
6478
|
+
case 'woocommerce_list_bookings':
|
|
6479
|
+
return await client.woocommerceListBookings(args);
|
|
6480
|
+
case 'woocommerce_get_booking':
|
|
6481
|
+
return await client.woocommerceGetBooking(args.id);
|
|
6482
|
+
case 'woocommerce_update_booking_status': {
|
|
6483
|
+
const { id, ...payload } = args;
|
|
6484
|
+
return await client.woocommerceUpdateBookingStatus(id, payload);
|
|
6485
|
+
}
|
|
6486
|
+
case 'woocommerce_reschedule_booking': {
|
|
6487
|
+
const { id, ...payload } = args;
|
|
6488
|
+
return await client.woocommerceRescheduleBooking(id, payload);
|
|
6489
|
+
}
|
|
6490
|
+
case 'woocommerce_list_membership_plans':
|
|
6491
|
+
return await client.woocommerceListMembershipPlans(args);
|
|
6492
|
+
case 'woocommerce_list_memberships':
|
|
6493
|
+
return await client.woocommerceListMemberships(args);
|
|
6494
|
+
case 'woocommerce_get_membership':
|
|
6495
|
+
return await client.woocommerceGetMembership(args.id);
|
|
6496
|
+
case 'woocommerce_update_membership_status': {
|
|
6497
|
+
const { id, ...payload } = args;
|
|
6498
|
+
return await client.woocommerceUpdateMembershipStatus(id, payload);
|
|
6499
|
+
}
|
|
6500
|
+
case 'woocommerce_set_membership_end_date': {
|
|
6501
|
+
const { id, ...payload } = args;
|
|
6502
|
+
return await client.woocommerceSetMembershipEndDate(id, payload);
|
|
6503
|
+
}
|
|
6504
|
+
case 'woocommerce_create_cart_link':
|
|
6505
|
+
return await client.woocommerceCreateCartLink(args);
|
|
6506
|
+
case 'woocommerce_agent_orders_report':
|
|
6507
|
+
return await client.woocommerceAgentOrdersReport(args);
|
|
6508
|
+
case 'woocommerce_readiness_fixlist':
|
|
6509
|
+
return await client.woocommerceReadinessFixlist(args);
|
|
6510
|
+
case 'woocommerce_set_image_alt': {
|
|
6511
|
+
const { id, ...payload } = args;
|
|
6512
|
+
return await client.woocommerceSetImageAlt(id, payload);
|
|
6513
|
+
}
|
|
6514
|
+
case 'woocommerce_configure_feed':
|
|
6515
|
+
return await client.woocommerceConfigureFeed(args);
|
|
6516
|
+
case 'woocommerce_generate_feed':
|
|
6517
|
+
return await client.woocommerceGenerateFeed(args);
|
|
6518
|
+
case 'woocommerce_get_feed_status':
|
|
6519
|
+
return await client.woocommerceGetFeedStatus();
|
|
6520
|
+
case 'woocommerce_validate_feed':
|
|
6521
|
+
return await client.woocommerceValidateFeed(args);
|
|
6522
|
+
case 'woocommerce_set_feed_category_mapping':
|
|
6523
|
+
return await client.woocommerceSetFeedCategoryMapping(args);
|
|
6377
6524
|
case 'woocommerce_product_ai_readiness':
|
|
6378
6525
|
return await client.woocommerceProductAiReadiness(args.id);
|
|
6379
6526
|
case 'woocommerce_catalog_ai_readiness':
|
|
@@ -6477,16 +6624,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6477
6624
|
if (elementorResult !== null) {
|
|
6478
6625
|
return elementorResult;
|
|
6479
6626
|
}
|
|
6480
|
-
// Check if this is an ACF tool.
|
|
6481
|
-
|
|
6482
|
-
// are keyed by respira_acf_*), but normalizeToolName blindly rewrites
|
|
6483
|
-
// respira_* -> wordpress_*, so by the time dispatchToolCall runs an ACF
|
|
6484
|
-
// call arrives as wordpress_acf_*. Map it back so the guard matches and
|
|
6485
|
-
// the plugin dispatch gets the name it expects. Without this every ACF
|
|
6486
|
-
// tool fell through to "Unknown tool: wordpress_acf_*" (kenbi/Pedro,
|
|
6487
|
-
// 2026-07-14; same class as the v7.1.0 fix that regressed).
|
|
6488
|
-
const acfName = resolveAcfToolName(name);
|
|
6489
|
-
if (acfName) {
|
|
6627
|
+
// Check if this is an ACF tool.
|
|
6628
|
+
if (ACF_TOOL_NAMES.has(name)) {
|
|
6490
6629
|
if (!(await this.isAcfAvailable())) {
|
|
6491
6630
|
return {
|
|
6492
6631
|
__respira_is_error: true,
|
|
@@ -6494,7 +6633,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6494
6633
|
hint: 'Install Advanced Custom Fields on the target site, then retry. ACF Pro is required for repeaters, flexible content, galleries, and options pages.'
|
|
6495
6634
|
};
|
|
6496
6635
|
}
|
|
6497
|
-
return await this.currentSite.dispatchAcfTool(
|
|
6636
|
+
return await this.currentSite.dispatchAcfTool(name, args);
|
|
6498
6637
|
}
|
|
6499
6638
|
// Check if this is a widget shortcut (wordpress_add_*).
|
|
6500
6639
|
if (name.startsWith('wordpress_add_')) {
|
|
@@ -6602,20 +6741,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6602
6741
|
type: 'number',
|
|
6603
6742
|
description: 'Position index within the target container (0-based). Pass -1 to append.',
|
|
6604
6743
|
},
|
|
6605
|
-
edit_target: {
|
|
6606
|
-
type: 'string',
|
|
6607
|
-
enum: ['live', 'duplicate'],
|
|
6608
|
-
description: 'Where to apply the write. "duplicate" (default) routes the edit to the Respira duplicate of the original (or auto-creates one) so changes go through the Respira → Changes approval flow before they reach the public page. "live" writes straight to the published original (requires Respira → Settings → Allow direct edit, or the original to be a draft / existing Respira duplicate). The response always reports the resolved target so you know exactly which post received the write.',
|
|
6609
|
-
},
|
|
6610
|
-
editTarget: {
|
|
6611
|
-
type: 'string',
|
|
6612
|
-
enum: ['live', 'duplicate'],
|
|
6613
|
-
description: 'Deprecated camelCase alias of edit_target. Prefer edit_target. Kept for back-compat with pre-v6.19.5 callers.',
|
|
6614
|
-
},
|
|
6615
|
-
confirm_live_edit: {
|
|
6616
|
-
type: 'boolean',
|
|
6617
|
-
description: 'Required when responding to a `confirmation_required` reply on the live path. Pass `true` alongside `edit_target: "live"` to acknowledge the published-original write. Has no effect when `edit_target` is `duplicate` or omitted.',
|
|
6618
|
-
},
|
|
6619
6744
|
},
|
|
6620
6745
|
required: ['post_id', 'identifier_type', 'identifier_value', 'target_container_path', 'position'],
|
|
6621
6746
|
},
|
|
@@ -6653,27 +6778,13 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6653
6778
|
post_id: { type: 'number', description: 'Page/post ID' },
|
|
6654
6779
|
identifier_type: {
|
|
6655
6780
|
type: 'string',
|
|
6656
|
-
enum: ['id', 'css_class', 'text', 'widget_type', 'global_id'
|
|
6657
|
-
description: 'How to locate the element: "id" (element ID), "css_class" (CSS class name), "text" (visible text content), "widget_type" (widget/module type), "global_id" (cross-page global element ID)
|
|
6781
|
+
enum: ['id', 'css_class', 'text', 'widget_type', 'global_id'],
|
|
6782
|
+
description: 'How to locate the element: "id" (element ID), "css_class" (CSS class name), "text" (visible text content), "widget_type" (widget/module type), or "global_id" (cross-page global element ID).',
|
|
6658
6783
|
},
|
|
6659
6784
|
identifier_value: {
|
|
6660
6785
|
type: 'string',
|
|
6661
6786
|
description: 'Value matching the chosen identifier_type (e.g. the element ID, the class name, the text to match).',
|
|
6662
6787
|
},
|
|
6663
|
-
edit_target: {
|
|
6664
|
-
type: 'string',
|
|
6665
|
-
enum: ['live', 'duplicate'],
|
|
6666
|
-
description: 'Where to apply the write. "duplicate" (default) routes the edit to the Respira duplicate of the original (or auto-creates one) so changes go through the Respira → Changes approval flow before they reach the public page. "live" writes straight to the published original (requires Respira → Settings → Allow direct edit, or the original to be a draft / existing Respira duplicate). The response always reports the resolved target so you know exactly which post received the write.',
|
|
6667
|
-
},
|
|
6668
|
-
editTarget: {
|
|
6669
|
-
type: 'string',
|
|
6670
|
-
enum: ['live', 'duplicate'],
|
|
6671
|
-
description: 'Deprecated camelCase alias of edit_target. Prefer edit_target. Kept for back-compat with pre-v6.19.5 callers.',
|
|
6672
|
-
},
|
|
6673
|
-
confirm_live_edit: {
|
|
6674
|
-
type: 'boolean',
|
|
6675
|
-
description: 'Required when responding to a `confirmation_required` reply on the live path. Pass `true` alongside `edit_target: "live"` to acknowledge the published-original write. Has no effect when `edit_target` is `duplicate` or omitted.',
|
|
6676
|
-
},
|
|
6677
6788
|
},
|
|
6678
6789
|
required: ['post_id', 'identifier_type', 'identifier_value'],
|
|
6679
6790
|
},
|
|
@@ -6723,20 +6834,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
|
|
|
6723
6834
|
description: 'Array of element IDs in the desired order.',
|
|
6724
6835
|
items: { type: 'string' },
|
|
6725
6836
|
},
|
|
6726
|
-
edit_target: {
|
|
6727
|
-
type: 'string',
|
|
6728
|
-
enum: ['live', 'duplicate'],
|
|
6729
|
-
description: 'Where to apply the write. "duplicate" (default) routes the edit to the Respira duplicate of the original (or auto-creates one) so changes go through the Respira → Changes approval flow before they reach the public page. "live" writes straight to the published original (requires Respira → Settings → Allow direct edit, or the original to be a draft / existing Respira duplicate). The response always reports the resolved target so you know exactly which post received the write.',
|
|
6730
|
-
},
|
|
6731
|
-
editTarget: {
|
|
6732
|
-
type: 'string',
|
|
6733
|
-
enum: ['live', 'duplicate'],
|
|
6734
|
-
description: 'Deprecated camelCase alias of edit_target. Prefer edit_target. Kept for back-compat with pre-v6.19.5 callers.',
|
|
6735
|
-
},
|
|
6736
|
-
confirm_live_edit: {
|
|
6737
|
-
type: 'boolean',
|
|
6738
|
-
description: 'Required when responding to a `confirmation_required` reply on the live path. Pass `true` alongside `edit_target: "live"` to acknowledge the published-original write. Has no effect when `edit_target` is `duplicate` or omitted.',
|
|
6739
|
-
},
|
|
6740
6837
|
},
|
|
6741
6838
|
required: ['post_id', 'container_path', 'new_order'],
|
|
6742
6839
|
},
|