@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.
Files changed (92) hide show
  1. package/dist/__tests__/site-list-self-heal.test.d.ts +18 -0
  2. package/dist/__tests__/site-list-self-heal.test.d.ts.map +1 -0
  3. package/dist/__tests__/site-list-self-heal.test.js +169 -0
  4. package/dist/__tests__/site-list-self-heal.test.js.map +1 -0
  5. package/dist/acf-tools.d.ts +0 -10
  6. package/dist/acf-tools.d.ts.map +1 -1
  7. package/dist/acf-tools.js +0 -15
  8. package/dist/acf-tools.js.map +1 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +31 -36
  11. package/dist/config.js.map +1 -1
  12. package/dist/server.d.ts +34 -46
  13. package/dist/server.d.ts.map +1 -1
  14. package/dist/server.js +560 -463
  15. package/dist/server.js.map +1 -1
  16. package/dist/wordpress-client.d.ts +33 -29
  17. package/dist/wordpress-client.d.ts.map +1 -1
  18. package/dist/wordpress-client.js +124 -148
  19. package/dist/wordpress-client.js.map +1 -1
  20. package/package.json +2 -2
  21. package/skills/activity-report-composer/README.md +38 -0
  22. package/skills/activity-report-composer/SKILL.md +167 -0
  23. package/skills/activity-report-composer/metadata.json +42 -0
  24. package/skills/brand-voice-synthesizer/README.md +43 -0
  25. package/skills/brand-voice-synthesizer/SKILL.md +259 -0
  26. package/skills/brand-voice-synthesizer/metadata.json +46 -0
  27. package/skills/build-oxygen6-page/SKILL.md +59 -0
  28. package/skills/conversion-audit/README.md +37 -0
  29. package/skills/conversion-audit/SKILL.md +185 -0
  30. package/skills/conversion-audit/metadata.json +46 -0
  31. package/skills/custom-post-type-architect/README.md +34 -0
  32. package/skills/custom-post-type-architect/SKILL.md +158 -0
  33. package/skills/custom-post-type-architect/metadata.json +39 -0
  34. package/skills/design-system-synthesizer/README.md +46 -0
  35. package/skills/design-system-synthesizer/SKILL.md +395 -0
  36. package/skills/design-system-synthesizer/metadata.json +57 -0
  37. package/skills/figma-to-elementor/SKILL.md +204 -0
  38. package/skills/html-to-bricks/README.md +43 -0
  39. package/skills/html-to-bricks/SKILL.md +135 -0
  40. package/skills/html-to-bricks/metadata.json +41 -0
  41. package/skills/internal-link-builder/SKILL.md +258 -0
  42. package/skills/internal-link-builder/metadata.json +46 -0
  43. package/skills/migrate-beaver-builder-to-bricks/SKILL.md +251 -0
  44. package/skills/migrate-beaver-builder-to-gutenberg/SKILL.md +248 -0
  45. package/skills/migrate-brizy-to-gutenberg/SKILL.md +248 -0
  46. package/skills/migrate-divi-to-breakdance/SKILL.md +312 -0
  47. package/skills/migrate-divi-to-bricks/SKILL.md +321 -0
  48. package/skills/migrate-divi-to-gutenberg/SKILL.md +328 -0
  49. package/skills/migrate-elementor-to-breakdance/SKILL.md +287 -0
  50. package/skills/migrate-elementor-to-bricks/SKILL.md +289 -0
  51. package/skills/migrate-elementor-to-gutenberg/SKILL.md +327 -0
  52. package/skills/migrate-elementor-to-oxygen/SKILL.md +300 -0
  53. package/skills/migrate-oxygen-to-breakdance/SKILL.md +235 -0
  54. package/skills/migrate-oxygen-to-bricks/SKILL.md +237 -0
  55. package/skills/migrate-thrive-architect-to-gutenberg/SKILL.md +257 -0
  56. package/skills/migrate-visual-composer-to-gutenberg/SKILL.md +261 -0
  57. package/skills/migrate-wpbakery-to-bricks/SKILL.md +336 -0
  58. package/skills/migrate-wpbakery-to-gutenberg/SKILL.md +345 -0
  59. package/skills/mobile-experience-report/SKILL.md +719 -0
  60. package/skills/mobile-experience-report/telemetry.ts +137 -0
  61. package/skills/page-template-library/README.md +41 -0
  62. package/skills/page-template-library/SKILL.md +175 -0
  63. package/skills/page-template-library/metadata.json +44 -0
  64. package/skills/prime-the-agent/README.md +49 -0
  65. package/skills/prime-the-agent/SKILL.md +225 -0
  66. package/skills/prime-the-agent/metadata.json +46 -0
  67. package/skills/respira-setup-assistant/SKILL.md +386 -0
  68. package/skills/respira-setup-assistant/metadata.json +36 -0
  69. package/skills/seo-aeo-amplifier/README.md +61 -0
  70. package/skills/seo-aeo-amplifier/SKILL.md +277 -0
  71. package/skills/seo-aeo-amplifier/metadata.json +62 -0
  72. package/skills/stale-content-detector/README.md +41 -0
  73. package/skills/stale-content-detector/SKILL.md +156 -0
  74. package/skills/stale-content-detector/metadata.json +43 -0
  75. package/skills/technical-debt-audit/SKILL.md +691 -0
  76. package/skills/technical-debt-audit/telemetry.ts +142 -0
  77. package/skills/woocommerce-health-check/SKILL.md +719 -0
  78. package/skills/woocommerce-health-check/telemetry.ts +137 -0
  79. package/skills/wordpress-ai-image-optimizer/README.md +49 -0
  80. package/skills/wordpress-ai-image-optimizer/SKILL.md +232 -0
  81. package/skills/wordpress-ai-image-optimizer/metadata.json +63 -0
  82. package/skills/wordpress-site-dna/README.md +81 -0
  83. package/skills/wordpress-site-dna/SKILL.md +669 -0
  84. package/skills/wordpress-site-dna/telemetry.ts +137 -0
  85. package/dist/__tests__/acf-tool-routing.test.d.ts +0 -2
  86. package/dist/__tests__/acf-tool-routing.test.d.ts.map +0 -1
  87. package/dist/__tests__/acf-tool-routing.test.js +0 -37
  88. package/dist/__tests__/acf-tool-routing.test.js.map +0 -1
  89. package/dist/__tests__/cowork-bootstrap.test.d.ts +0 -2
  90. package/dist/__tests__/cowork-bootstrap.test.d.ts.map +0 -1
  91. package/dist/__tests__/cowork-bootstrap.test.js +0 -61
  92. 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, resolveAcfToolName } from './acf-tools.js';
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 account site-list refresh (throttle network calls). */
266
+ /** Epoch ms of the last site-list self-heal against respira.press (throttle). */
329
267
  lastSiteRefreshAt = 0;
330
- static SITE_REFRESH_MIN_INTERVAL_MS = 60_000;
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
- There are TWO different Oxygen builders. Check get_builder_info to know which is active.
575
- - **Oxygen Classic** (the ct_* shortcode builder): content in ct_builder_shortcodes / ct_builder_json meta; templates use the ct_template post type.
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
- // FAIL LOUD when the server is pinned to RESPIRA_CONFIG_B64. The config load
1228
- // order is RESPIRA_CONFIG_B64 -> RESPIRA_CONFIG_FILE -> ~/.respira/config.json
1229
- // (see config.ts), so the file we just wrote is OVERRIDDEN by the env var and
1230
- // the freshly-redeemed token never takes effect. The old code reported
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: false,
1259
- message: `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.`,
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`.\n\nLARGE SECTIONS (payload cap): if a fully styled section is too big to send in one call (some AI clients cap tool-call payload size), build it in pieces with `target_path`. First create the section frame (the section + rows + empty columns) in one small call, then append each module into its column with separate small calls: `target_path: "sections[0].rows[0].columns[1]"`, `position: -1` (append to end). Get exact bracket paths from `extract_builder_content`. Do NOT split one section across multiple top-level `mode:"append"` calls — append adds each call as a NEW section, so you would get several sections instead of one. `target_path` insert is supported on Divi 5 today; on other builders it returns `respira_target_path_unsupported` (use the lean-skeleton + `update_module` approach there).',
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 stale list: re-pull the account's current sites so a site
5759
- // added on the dashboard after this server booted shows up without a
5760
- // re-paste or restart. Best-effort and throttled; never blocks the list.
5761
- const refresh = await this.refreshSitesFromAccount().catch(() => ({ added: [] }));
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, args.target_path, args.position)),
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, args.confirm_live_edit)),
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
- const newSite = this.sites.get(args.site_id);
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
- throw new Error(`Site with ID "${args.site_id}" not found in configuration`);
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, args.confirm_live_edit);
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. ACF tools live only under the
6481
- // respira_acf_* namespace (ACF_TOOL_NAMES + wordpress-client ACF_ROUTES
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(acfName, args);
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', 'path'],
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), or "path" (position in the tree, e.g. "0.1.2"). Use "path" to remove an element that has no text and shares a class with its siblings, such as an empty wrapper left after its only child was removed; find_element returns each element\'s path in its candidate list.',
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
  },