@respira/wordpress-mcp-server 7.3.0 → 7.4.0

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 (88) hide show
  1. package/dist/acf-tools.d.ts +0 -10
  2. package/dist/acf-tools.d.ts.map +1 -1
  3. package/dist/acf-tools.js +0 -15
  4. package/dist/acf-tools.js.map +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +31 -36
  7. package/dist/config.js.map +1 -1
  8. package/dist/server.d.ts +0 -47
  9. package/dist/server.d.ts.map +1 -1
  10. package/dist/server.js +404 -462
  11. package/dist/server.js.map +1 -1
  12. package/dist/wordpress-client.d.ts +26 -29
  13. package/dist/wordpress-client.d.ts.map +1 -1
  14. package/dist/wordpress-client.js +115 -148
  15. package/dist/wordpress-client.js.map +1 -1
  16. package/package.json +2 -2
  17. package/skills/activity-report-composer/README.md +38 -0
  18. package/skills/activity-report-composer/SKILL.md +167 -0
  19. package/skills/activity-report-composer/metadata.json +42 -0
  20. package/skills/brand-voice-synthesizer/README.md +43 -0
  21. package/skills/brand-voice-synthesizer/SKILL.md +259 -0
  22. package/skills/brand-voice-synthesizer/metadata.json +46 -0
  23. package/skills/build-oxygen6-page/SKILL.md +59 -0
  24. package/skills/conversion-audit/README.md +37 -0
  25. package/skills/conversion-audit/SKILL.md +185 -0
  26. package/skills/conversion-audit/metadata.json +46 -0
  27. package/skills/custom-post-type-architect/README.md +34 -0
  28. package/skills/custom-post-type-architect/SKILL.md +158 -0
  29. package/skills/custom-post-type-architect/metadata.json +39 -0
  30. package/skills/design-system-synthesizer/README.md +46 -0
  31. package/skills/design-system-synthesizer/SKILL.md +395 -0
  32. package/skills/design-system-synthesizer/metadata.json +57 -0
  33. package/skills/figma-to-elementor/SKILL.md +204 -0
  34. package/skills/html-to-bricks/README.md +43 -0
  35. package/skills/html-to-bricks/SKILL.md +135 -0
  36. package/skills/html-to-bricks/metadata.json +41 -0
  37. package/skills/internal-link-builder/SKILL.md +258 -0
  38. package/skills/internal-link-builder/metadata.json +46 -0
  39. package/skills/migrate-beaver-builder-to-bricks/SKILL.md +251 -0
  40. package/skills/migrate-beaver-builder-to-gutenberg/SKILL.md +248 -0
  41. package/skills/migrate-brizy-to-gutenberg/SKILL.md +248 -0
  42. package/skills/migrate-divi-to-breakdance/SKILL.md +312 -0
  43. package/skills/migrate-divi-to-bricks/SKILL.md +321 -0
  44. package/skills/migrate-divi-to-gutenberg/SKILL.md +328 -0
  45. package/skills/migrate-elementor-to-breakdance/SKILL.md +287 -0
  46. package/skills/migrate-elementor-to-bricks/SKILL.md +289 -0
  47. package/skills/migrate-elementor-to-gutenberg/SKILL.md +327 -0
  48. package/skills/migrate-elementor-to-oxygen/SKILL.md +300 -0
  49. package/skills/migrate-oxygen-to-breakdance/SKILL.md +235 -0
  50. package/skills/migrate-oxygen-to-bricks/SKILL.md +237 -0
  51. package/skills/migrate-thrive-architect-to-gutenberg/SKILL.md +257 -0
  52. package/skills/migrate-visual-composer-to-gutenberg/SKILL.md +261 -0
  53. package/skills/migrate-wpbakery-to-bricks/SKILL.md +336 -0
  54. package/skills/migrate-wpbakery-to-gutenberg/SKILL.md +345 -0
  55. package/skills/mobile-experience-report/SKILL.md +719 -0
  56. package/skills/mobile-experience-report/telemetry.ts +137 -0
  57. package/skills/page-template-library/README.md +41 -0
  58. package/skills/page-template-library/SKILL.md +175 -0
  59. package/skills/page-template-library/metadata.json +44 -0
  60. package/skills/prime-the-agent/README.md +49 -0
  61. package/skills/prime-the-agent/SKILL.md +225 -0
  62. package/skills/prime-the-agent/metadata.json +46 -0
  63. package/skills/respira-setup-assistant/SKILL.md +386 -0
  64. package/skills/respira-setup-assistant/metadata.json +36 -0
  65. package/skills/seo-aeo-amplifier/README.md +61 -0
  66. package/skills/seo-aeo-amplifier/SKILL.md +277 -0
  67. package/skills/seo-aeo-amplifier/metadata.json +62 -0
  68. package/skills/stale-content-detector/README.md +41 -0
  69. package/skills/stale-content-detector/SKILL.md +156 -0
  70. package/skills/stale-content-detector/metadata.json +43 -0
  71. package/skills/technical-debt-audit/SKILL.md +691 -0
  72. package/skills/technical-debt-audit/telemetry.ts +142 -0
  73. package/skills/woocommerce-health-check/SKILL.md +719 -0
  74. package/skills/woocommerce-health-check/telemetry.ts +137 -0
  75. package/skills/wordpress-ai-image-optimizer/README.md +49 -0
  76. package/skills/wordpress-ai-image-optimizer/SKILL.md +232 -0
  77. package/skills/wordpress-ai-image-optimizer/metadata.json +63 -0
  78. package/skills/wordpress-site-dna/README.md +81 -0
  79. package/skills/wordpress-site-dna/SKILL.md +669 -0
  80. package/skills/wordpress-site-dna/telemetry.ts +137 -0
  81. package/dist/__tests__/acf-tool-routing.test.d.ts +0 -2
  82. package/dist/__tests__/acf-tool-routing.test.d.ts.map +0 -1
  83. package/dist/__tests__/acf-tool-routing.test.js +0 -37
  84. package/dist/__tests__/acf-tool-routing.test.js.map +0 -1
  85. package/dist/__tests__/cowork-bootstrap.test.d.ts +0 -2
  86. package/dist/__tests__/cowork-bootstrap.test.d.ts.map +0 -1
  87. package/dist/__tests__/cowork-bootstrap.test.js +0 -61
  88. 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,6 @@ 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). */
329
- lastSiteRefreshAt = 0;
330
- static SITE_REFRESH_MIN_INTERVAL_MS = 60_000;
331
266
  static MCP_SERVER_VERSION = MCP_SERVER_VERSION;
332
267
  /**
333
268
  * Normalize a tool name: respira_* → wordpress_* for switch dispatch.
@@ -570,14 +505,9 @@ Use respira_get_builder_info first to detect which builder is active. Then use t
570
505
  - _nextNodeId tracking for proper element ID sequencing after injection
571
506
  - respira_find_element and respira_update_element work via tree traversal with full element schema
572
507
 
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.
508
+ ### Oxygen (Smart Defaults)
509
+ - Content stored in ct_builder_shortcodes and/or ct_builder_json meta
510
+ - Templates use ct_template post type
581
511
  - respira_find_element works via tree traversal on the parsed structure
582
512
 
583
513
  ### WPBakery (Smart Defaults)
@@ -706,124 +636,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
706
636
  getSiteSummary(site) {
707
637
  return site.getSiteSummary(site.getSiteId() === this.defaultSiteId);
708
638
  }
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
639
  /**
828
640
  * N6 fix (v6.19.0): the response envelope `site` field must reflect the
829
641
  * client that ACTUALLY serviced the call, not the global default. Pre-fix,
@@ -915,14 +727,9 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
915
727
  const apiBase = process.env.RESPIRA_API_BASE || 'https://www.respira.press';
916
728
  const url = `${apiBase.replace(/\/+$/, '')}/docs-search?q=${encodeURIComponent(query)}&limit=${limit}`;
917
729
  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
730
  const response = await fetch(url, {
923
731
  method: 'GET',
924
732
  headers: { 'User-Agent': `respira-mcp/${MCP_SERVER_VERSION}` },
925
- signal: AbortSignal.timeout(20_000),
926
733
  });
927
734
  if (!response.ok) {
928
735
  return {
@@ -1224,39 +1031,17 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1224
1031
  url: s.url,
1225
1032
  }));
1226
1033
  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
- }
1034
+ 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.`;
1035
+ const message = hasConfigB64
1036
+ ? `${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.`
1037
+ : baseMessage;
1253
1038
  return {
1254
1039
  success: true,
1255
1040
  sites_count: sites.length,
1256
1041
  sites,
1257
1042
  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.`,
1043
+ config_b64_active: hasConfigB64,
1044
+ message,
1260
1045
  };
1261
1046
  }
1262
1047
  /**
@@ -1368,12 +1153,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1368
1153
  // (wordpress_redeem_token) because normalizeToolName rewrites the
1369
1154
  // respira_* alias to wordpress_* before dispatch.
1370
1155
  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
1156
  if (canonical === 'wordpress_redeem_token') {
1378
1157
  // redeemInstallToken returns a plain object. Wrap it in the standard
1379
1158
  // MCP content envelope — the early-exit path bypasses the normal
@@ -1475,18 +1254,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1475
1254
  timeout_ms: error.timeoutMs,
1476
1255
  message: error.message,
1477
1256
  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
1257
  'If this operation genuinely needs more time, raise RESPIRA_MAX_TOOL_TIMEOUT_MS. ' +
1491
1258
  'For uploads specifically, also see RESPIRA_UPLOAD_TIMEOUT_MS and RESPIRA_MAX_UPLOAD_MB. ' +
1492
1259
  '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 +2051,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2284
2051
  },
2285
2052
  {
2286
2053
  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).',
2054
+ 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
2055
  inputSchema: {
2289
2056
  type: 'object',
2290
2057
  properties: {
@@ -2319,14 +2086,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2319
2086
  description: 'Divi generation hint ("4" for shortcode format, "5" for block format). Required for all Divi inject calls.',
2320
2087
  enum: ['4', '5'],
2321
2088
  },
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
2089
  },
2331
2090
  required: ['builder', 'page_id', 'content'],
2332
2091
  },
@@ -2409,10 +2168,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2409
2168
  description: 'When editing an original, choose ask, live, or duplicate. Defaults to ask when direct editing is enabled.',
2410
2169
  enum: ['ask', 'live', 'duplicate'],
2411
2170
  },
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
2171
  },
2417
2172
  required: ['builder', 'page_id', 'module_identifier', 'updates'],
2418
2173
  },
@@ -2573,10 +2328,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2573
2328
  description: 'When editing an original, choose ask, live, or duplicate. Defaults to ask when direct editing is enabled.',
2574
2329
  enum: ['ask', 'live', 'duplicate'],
2575
2330
  },
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
2331
  },
2581
2332
  required: ['builder', 'post_id', 'operations'],
2582
2333
  },
@@ -2647,35 +2398,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2647
2398
  required: ['relative_path', 'content'],
2648
2399
  },
2649
2400
  },
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
2401
  {
2680
2402
  name: 'wordpress_switch_site',
2681
2403
  description: 'Switch to a different WordPress site in the active Respira multi-site configuration.',
@@ -4061,21 +3783,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4061
3783
  },
4062
3784
  destructiveHint: true,
4063
3785
  },
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
3786
  // Options
4080
3787
  {
4081
3788
  name: 'wordpress_list_options',
@@ -4528,56 +4235,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4528
4235
  /** Cached site context for tool filtering (refreshed on site switch). */
4529
4236
  cachedFilterContext = null;
4530
4237
  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
4238
  async filterToolsByContext(tools) {
4582
4239
  if (!this.currentSite) {
4583
4240
  return tools; // No site connected — show everything.
@@ -4596,22 +4253,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4596
4253
  return tools; // Can't fetch context — show everything.
4597
4254
  }
4598
4255
  }
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
4256
  const detectedBuilder = (context?.page_builder?.name || '').toLowerCase();
4616
4257
  const hasWooCommerce = Boolean(context?.woocommerce?.active || context?.addons?.woocommerce?.installed);
4617
4258
  return tools.filter((tool) => {
@@ -4636,20 +4277,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4636
4277
  if (name.startsWith('woocommerce_')) {
4637
4278
  return hasWooCommerce;
4638
4279
  }
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
4280
  // Everything else: always show.
4654
4281
  return true;
4655
4282
  });
@@ -4767,6 +4394,15 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4767
4394
  items: { type: 'object' },
4768
4395
  description: 'Alternative tag payload (IDs, names, slugs, or objects with id/name/slug)',
4769
4396
  },
4397
+ global_unique_id: {
4398
+ type: 'string',
4399
+ description: 'GTIN/UPC/EAN (WooCommerce 9.2+). The strongest identifier for feeds and AI shopping.',
4400
+ },
4401
+ gallery_image_ids: {
4402
+ type: 'array',
4403
+ items: { type: 'number' },
4404
+ description: 'Gallery image attachment IDs; empty array clears the gallery',
4405
+ },
4770
4406
  },
4771
4407
  required: ['id'],
4772
4408
  },
@@ -5487,6 +5123,320 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5487
5123
  },
5488
5124
  destructiveHint: true,
5489
5125
  },
5126
+ // --- Subscriptions / Bookings / Memberships (addon v3.1, extensions) ---
5127
+ {
5128
+ name: 'woocommerce_list_subscriptions',
5129
+ description: 'List WooCommerce Subscriptions, filterable by status, customer, or product. Requires the WooCommerce Subscriptions extension on the site.',
5130
+ inputSchema: {
5131
+ type: 'object',
5132
+ properties: {
5133
+ status: { type: 'string', description: 'active, on-hold, cancelled, pending-cancel, expired' },
5134
+ customer_id: { type: 'number' },
5135
+ product_id: { type: 'number' },
5136
+ per_page: { type: 'number' },
5137
+ page: { type: 'number' },
5138
+ },
5139
+ },
5140
+ readOnlyHint: true,
5141
+ },
5142
+ {
5143
+ name: 'woocommerce_get_subscription',
5144
+ description: 'Get one subscription with items, dates, and payment method.',
5145
+ inputSchema: {
5146
+ type: 'object',
5147
+ properties: {
5148
+ id: { type: 'number', description: 'Subscription ID' },
5149
+ },
5150
+ required: ['id'],
5151
+ },
5152
+ readOnlyHint: true,
5153
+ },
5154
+ {
5155
+ name: 'woocommerce_update_subscription_status',
5156
+ 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.',
5157
+ inputSchema: {
5158
+ type: 'object',
5159
+ properties: {
5160
+ id: { type: 'number', description: 'Subscription ID' },
5161
+ status: { type: 'string', description: 'active, on-hold, cancelled, pending-cancel' },
5162
+ note: { type: 'string' },
5163
+ dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
5164
+ },
5165
+ required: ['id', 'status'],
5166
+ },
5167
+ destructiveHint: true,
5168
+ },
5169
+ {
5170
+ name: 'woocommerce_update_subscription_dates',
5171
+ 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.',
5172
+ inputSchema: {
5173
+ type: 'object',
5174
+ properties: {
5175
+ id: { type: 'number', description: 'Subscription ID' },
5176
+ next_payment: { type: 'string', description: 'ISO 8601' },
5177
+ end: { type: 'string' },
5178
+ trial_end: { type: 'string' },
5179
+ dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
5180
+ },
5181
+ required: ['id'],
5182
+ },
5183
+ destructiveHint: true,
5184
+ },
5185
+ {
5186
+ name: 'woocommerce_add_subscription_note',
5187
+ description: 'Add a note to a subscription. Private by default; customer_note: true emails the customer.',
5188
+ inputSchema: {
5189
+ type: 'object',
5190
+ properties: {
5191
+ id: { type: 'number', description: 'Subscription ID' },
5192
+ note: { type: 'string' },
5193
+ customer_note: { type: 'boolean', description: 'Default false (private note)' },
5194
+ },
5195
+ required: ['id', 'note'],
5196
+ },
5197
+ },
5198
+ {
5199
+ name: 'woocommerce_list_bookings',
5200
+ description: 'List WooCommerce Bookings, filterable by status or product. Requires the WooCommerce Bookings extension on the site.',
5201
+ inputSchema: {
5202
+ type: 'object',
5203
+ properties: {
5204
+ status: { type: 'string', description: 'unpaid, pending-confirmation, confirmed, paid, cancelled, complete' },
5205
+ product_id: { type: 'number' },
5206
+ per_page: { type: 'number' },
5207
+ page: { type: 'number' },
5208
+ },
5209
+ },
5210
+ readOnlyHint: true,
5211
+ },
5212
+ {
5213
+ name: 'woocommerce_get_booking',
5214
+ description: 'Get one booking with times, persons, resource, and order linkage.',
5215
+ inputSchema: {
5216
+ type: 'object',
5217
+ properties: {
5218
+ id: { type: 'number', description: 'Booking ID' },
5219
+ },
5220
+ required: ['id'],
5221
+ },
5222
+ readOnlyHint: true,
5223
+ },
5224
+ {
5225
+ name: 'woocommerce_update_booking_status',
5226
+ description: 'Confirm, mark paid, complete, or cancel a booking. DRY-RUN BY DEFAULT: status changes can trigger customer emails.',
5227
+ inputSchema: {
5228
+ type: 'object',
5229
+ properties: {
5230
+ id: { type: 'number', description: 'Booking ID' },
5231
+ status: { type: 'string', description: 'unpaid, pending-confirmation, confirmed, paid, cancelled, complete' },
5232
+ dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
5233
+ },
5234
+ required: ['id', 'status'],
5235
+ },
5236
+ destructiveHint: true,
5237
+ },
5238
+ {
5239
+ name: 'woocommerce_reschedule_booking',
5240
+ 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.',
5241
+ inputSchema: {
5242
+ type: 'object',
5243
+ properties: {
5244
+ id: { type: 'number', description: 'Booking ID' },
5245
+ start: { type: 'string', description: 'ISO 8601' },
5246
+ end: { type: 'string', description: 'ISO 8601' },
5247
+ force: { type: 'boolean', description: 'Apply despite conflicts' },
5248
+ dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
5249
+ },
5250
+ required: ['id', 'start', 'end'],
5251
+ },
5252
+ destructiveHint: true,
5253
+ },
5254
+ {
5255
+ name: 'woocommerce_list_membership_plans',
5256
+ description: 'All membership plans with member counts. Requires the WooCommerce Memberships extension on the site.',
5257
+ inputSchema: {
5258
+ type: 'object',
5259
+ properties: {},
5260
+ },
5261
+ readOnlyHint: true,
5262
+ },
5263
+ {
5264
+ name: 'woocommerce_list_memberships',
5265
+ description: 'List user memberships, filterable by plan, customer, or status.',
5266
+ inputSchema: {
5267
+ type: 'object',
5268
+ properties: {
5269
+ plan_id: { type: 'number' },
5270
+ customer_id: { type: 'number' },
5271
+ status: { type: 'string', description: 'active, paused, cancelled, expired, pending' },
5272
+ per_page: { type: 'number' },
5273
+ page: { type: 'number' },
5274
+ },
5275
+ },
5276
+ readOnlyHint: true,
5277
+ },
5278
+ {
5279
+ name: 'woocommerce_get_membership',
5280
+ description: 'Get one user membership with plan, dates, and order linkage.',
5281
+ inputSchema: {
5282
+ type: 'object',
5283
+ properties: {
5284
+ id: { type: 'number', description: 'Membership ID' },
5285
+ },
5286
+ required: ['id'],
5287
+ },
5288
+ readOnlyHint: true,
5289
+ },
5290
+ {
5291
+ name: 'woocommerce_update_membership_status',
5292
+ description: 'Pause, resume, or cancel a member\'s access. DRY-RUN BY DEFAULT: the change takes effect immediately for the member.',
5293
+ inputSchema: {
5294
+ type: 'object',
5295
+ properties: {
5296
+ id: { type: 'number', description: 'Membership ID' },
5297
+ membership_action: { type: 'string', description: 'pause, resume, or cancel' },
5298
+ note: { type: 'string' },
5299
+ dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
5300
+ },
5301
+ required: ['id', 'membership_action'],
5302
+ },
5303
+ destructiveHint: true,
5304
+ },
5305
+ {
5306
+ name: 'woocommerce_set_membership_end_date',
5307
+ description: 'Move or clear a membership\'s expiry date (empty string = never expires). DRY-RUN BY DEFAULT.',
5308
+ inputSchema: {
5309
+ type: 'object',
5310
+ properties: {
5311
+ id: { type: 'number', description: 'Membership ID' },
5312
+ end_date: { type: 'string', description: 'ISO 8601; empty string = never expires' },
5313
+ dry_run: { type: 'boolean', description: 'Default TRUE: preview only' },
5314
+ },
5315
+ required: ['id', 'end_date'],
5316
+ },
5317
+ destructiveHint: true,
5318
+ },
5319
+ // --- Assisted checkout (addon v3.1) ---
5320
+ {
5321
+ name: 'woocommerce_create_cart_link',
5322
+ 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.',
5323
+ inputSchema: {
5324
+ type: 'object',
5325
+ properties: {
5326
+ items: {
5327
+ type: 'array',
5328
+ items: { type: 'object' },
5329
+ description: 'Each: {product_id or variation_id, quantity}. Variable products need a variation_id.',
5330
+ },
5331
+ utm_source: { type: 'string', description: 'Attribution source, e.g. chatgpt.com (default ai-agent)' },
5332
+ replace_cart: { type: 'boolean', description: 'Empty the shopper\'s cart first (default false)' },
5333
+ expires_in_days: { type: 'number', description: 'Default 30; 0 = never expires' },
5334
+ },
5335
+ required: ['items'],
5336
+ },
5337
+ },
5338
+ {
5339
+ name: 'woocommerce_agent_orders_report',
5340
+ description: 'Orders and revenue that arrived through agent cart links over a period, with a per-source breakdown.',
5341
+ inputSchema: {
5342
+ type: 'object',
5343
+ properties: {
5344
+ days: { type: 'number', description: 'Period in days (default 30, max 365)' },
5345
+ },
5346
+ },
5347
+ readOnlyHint: true,
5348
+ },
5349
+ // --- Readiness autofix (addon v3.1) ---
5350
+ {
5351
+ name: 'woocommerce_readiness_fixlist',
5352
+ 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.',
5353
+ inputSchema: {
5354
+ type: 'object',
5355
+ properties: {
5356
+ limit: { type: 'number', description: 'Products to scan (default 100, max 500)' },
5357
+ target_score: { type: 'number', description: 'Skip products at or above this score (default 90)' },
5358
+ },
5359
+ },
5360
+ readOnlyHint: true,
5361
+ },
5362
+ {
5363
+ name: 'woocommerce_set_image_alt',
5364
+ description: 'Set alt text on a product\'s featured image (default) or a specific gallery image. The readiness check that fails most often.',
5365
+ inputSchema: {
5366
+ type: 'object',
5367
+ properties: {
5368
+ id: { type: 'number', description: 'Product ID' },
5369
+ alt: { type: 'string' },
5370
+ attachment_id: { type: 'number', description: 'Optional gallery image; defaults to the featured image' },
5371
+ },
5372
+ required: ['id', 'alt'],
5373
+ },
5374
+ idempotentHint: true,
5375
+ },
5376
+ // --- Product feeds + llms.txt (addon v3.1) ---
5377
+ {
5378
+ name: 'woocommerce_configure_feed',
5379
+ description: 'Read or update product feed settings (formats, cadence, countries, llms.txt) and get the public feed URLs. Call with no params to read.',
5380
+ inputSchema: {
5381
+ type: 'object',
5382
+ properties: {
5383
+ enabled_formats: {
5384
+ type: 'array',
5385
+ items: { type: 'string' },
5386
+ description: 'Any of: google, openai, meta, pinterest, tiktok, csv',
5387
+ },
5388
+ cadence: { type: 'string', description: 'hourly, twicedaily, or daily' },
5389
+ include_out_of_stock: { type: 'boolean' },
5390
+ target_countries: { type: 'array', items: { type: 'string' }, description: 'ISO 3166-1 alpha-2' },
5391
+ store_country: { type: 'string' },
5392
+ llms_txt: { type: 'boolean', description: 'Serve a store llms.txt at the site root' },
5393
+ rotate_token: { type: 'boolean', description: 'Rotate the secret feed URLs' },
5394
+ },
5395
+ },
5396
+ idempotentHint: true,
5397
+ },
5398
+ {
5399
+ name: 'woocommerce_generate_feed',
5400
+ 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.',
5401
+ inputSchema: {
5402
+ type: 'object',
5403
+ properties: {
5404
+ format: { type: 'string', description: 'Optional: enable and build one specific format' },
5405
+ },
5406
+ },
5407
+ },
5408
+ {
5409
+ name: 'woocommerce_get_feed_status',
5410
+ description: 'Per-format feed URLs, row counts, generation times, running build progress, and wp-cron health warnings.',
5411
+ inputSchema: {
5412
+ type: 'object',
5413
+ properties: {},
5414
+ },
5415
+ readOnlyHint: true,
5416
+ },
5417
+ {
5418
+ name: 'woocommerce_validate_feed',
5419
+ 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.',
5420
+ inputSchema: {
5421
+ type: 'object',
5422
+ properties: {
5423
+ format: { type: 'string', description: 'google (default), openai, meta, pinterest, tiktok, csv' },
5424
+ limit: { type: 'number', description: 'Parent products to sample (default 100, max 200)' },
5425
+ },
5426
+ },
5427
+ readOnlyHint: true,
5428
+ },
5429
+ {
5430
+ name: 'woocommerce_set_feed_category_mapping',
5431
+ 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.',
5432
+ inputSchema: {
5433
+ type: 'object',
5434
+ properties: {
5435
+ mappings: { type: 'object', description: '{"<term_id>": "<google category id or full path>"}; empty string removes a mapping' },
5436
+ },
5437
+ },
5438
+ idempotentHint: true,
5439
+ },
5490
5440
  // --- AI readiness (addon v3.0) ---
5491
5441
  {
5492
5442
  name: 'woocommerce_product_ai_readiness',
@@ -5755,10 +5705,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5755
5705
  ? await client.getSiteContext()
5756
5706
  : await client.getCompactSiteContext();
5757
5707
  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: [] }));
5762
5708
  const allSites = Array.from(this.sites.values());
5763
5709
  const visibleSites = allSites.filter((site) => this.isSiteAllowed(site));
5764
5710
  // v6.17.2: surface hidden sites so the AI can explain
@@ -5771,13 +5717,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5771
5717
  return {
5772
5718
  sites: visibleSites.map((site) => this.getSiteSummary(site)),
5773
5719
  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
5720
  ...(hiddenSites.length > 0
5782
5721
  ? {
5783
5722
  hidden_by_filter: hiddenSites,
@@ -5919,14 +5858,14 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5919
5858
  return await client.getBuilderInlineSchemas(args.builder, args.types);
5920
5859
  case 'wordpress_inject_builder_content':
5921
5860
  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)),
5861
+ ...(await client.injectBuilderContent(args.builder, args.page_id, args.content, args.divi_version, args.edit_target, args.mode, args.confirm_replace)),
5923
5862
  respira_approvals_url: client.getApprovalsUrl(),
5924
5863
  });
5925
5864
  case 'wordpress_make_responsive':
5926
5865
  return await client.makeResponsive(args.page_id, args.dry_run || false);
5927
5866
  case 'wordpress_update_module':
5928
5867
  return hoistDroppedStylingWarnings({
5929
- ...(await client.updateModule(args.builder, args.page_id, args.module_identifier, args.updates, args.edit_target, args.confirm_live_edit)),
5868
+ ...(await client.updateModule(args.builder, args.page_id, args.module_identifier, args.updates, args.edit_target)),
5930
5869
  respira_approvals_url: client.getApprovalsUrl(),
5931
5870
  });
5932
5871
  case 'wordpress_validate_security':
@@ -5943,13 +5882,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
5943
5882
  ...(await client.appendThemeFile(args.relative_path, args.content)),
5944
5883
  respira_approvals_url: client.getApprovalsUrl(),
5945
5884
  };
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
5885
  case 'wordpress_switch_site': {
5954
5886
  const newSite = this.sites.get(args.site_id);
5955
5887
  if (!newSite) {
@@ -6176,8 +6108,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6176
6108
  confirm_live_edit: args.confirm_live_edit,
6177
6109
  approval_token: args.approval_token,
6178
6110
  });
6179
- case 'wordpress_detach_duplicate':
6180
- return await client.callRestV1('POST', `/duplicates/${args.duplicate_id}/detach`);
6181
6111
  // Options
6182
6112
  case 'wordpress_list_options':
6183
6113
  return await client.listOptions(args.search);
@@ -6238,7 +6168,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6238
6168
  case 'wordpress_end_session':
6239
6169
  return await client.callRestV2('POST', '/snapshots/session/end', {});
6240
6170
  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);
6171
+ return await client.applyBuilderPatch(args.builder, args.post_id, args.operations, args.include, args.edit_target);
6242
6172
  case 'woocommerce_list_products':
6243
6173
  return await client.woocommerceListProducts(args);
6244
6174
  case 'woocommerce_get_product':
@@ -6374,6 +6304,68 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6374
6304
  const { id, ...payload } = args;
6375
6305
  return await client.woocommerceCreateOrderRefund(id, payload);
6376
6306
  }
6307
+ case 'woocommerce_list_subscriptions':
6308
+ return await client.woocommerceListSubscriptions(args);
6309
+ case 'woocommerce_get_subscription':
6310
+ return await client.woocommerceGetSubscription(args.id);
6311
+ case 'woocommerce_update_subscription_status': {
6312
+ const { id, ...payload } = args;
6313
+ return await client.woocommerceUpdateSubscriptionStatus(id, payload);
6314
+ }
6315
+ case 'woocommerce_update_subscription_dates': {
6316
+ const { id, ...payload } = args;
6317
+ return await client.woocommerceUpdateSubscriptionDates(id, payload);
6318
+ }
6319
+ case 'woocommerce_add_subscription_note': {
6320
+ const { id, ...payload } = args;
6321
+ return await client.woocommerceAddSubscriptionNote(id, payload);
6322
+ }
6323
+ case 'woocommerce_list_bookings':
6324
+ return await client.woocommerceListBookings(args);
6325
+ case 'woocommerce_get_booking':
6326
+ return await client.woocommerceGetBooking(args.id);
6327
+ case 'woocommerce_update_booking_status': {
6328
+ const { id, ...payload } = args;
6329
+ return await client.woocommerceUpdateBookingStatus(id, payload);
6330
+ }
6331
+ case 'woocommerce_reschedule_booking': {
6332
+ const { id, ...payload } = args;
6333
+ return await client.woocommerceRescheduleBooking(id, payload);
6334
+ }
6335
+ case 'woocommerce_list_membership_plans':
6336
+ return await client.woocommerceListMembershipPlans(args);
6337
+ case 'woocommerce_list_memberships':
6338
+ return await client.woocommerceListMemberships(args);
6339
+ case 'woocommerce_get_membership':
6340
+ return await client.woocommerceGetMembership(args.id);
6341
+ case 'woocommerce_update_membership_status': {
6342
+ const { id, ...payload } = args;
6343
+ return await client.woocommerceUpdateMembershipStatus(id, payload);
6344
+ }
6345
+ case 'woocommerce_set_membership_end_date': {
6346
+ const { id, ...payload } = args;
6347
+ return await client.woocommerceSetMembershipEndDate(id, payload);
6348
+ }
6349
+ case 'woocommerce_create_cart_link':
6350
+ return await client.woocommerceCreateCartLink(args);
6351
+ case 'woocommerce_agent_orders_report':
6352
+ return await client.woocommerceAgentOrdersReport(args);
6353
+ case 'woocommerce_readiness_fixlist':
6354
+ return await client.woocommerceReadinessFixlist(args);
6355
+ case 'woocommerce_set_image_alt': {
6356
+ const { id, ...payload } = args;
6357
+ return await client.woocommerceSetImageAlt(id, payload);
6358
+ }
6359
+ case 'woocommerce_configure_feed':
6360
+ return await client.woocommerceConfigureFeed(args);
6361
+ case 'woocommerce_generate_feed':
6362
+ return await client.woocommerceGenerateFeed(args);
6363
+ case 'woocommerce_get_feed_status':
6364
+ return await client.woocommerceGetFeedStatus();
6365
+ case 'woocommerce_validate_feed':
6366
+ return await client.woocommerceValidateFeed(args);
6367
+ case 'woocommerce_set_feed_category_mapping':
6368
+ return await client.woocommerceSetFeedCategoryMapping(args);
6377
6369
  case 'woocommerce_product_ai_readiness':
6378
6370
  return await client.woocommerceProductAiReadiness(args.id);
6379
6371
  case 'woocommerce_catalog_ai_readiness':
@@ -6477,16 +6469,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6477
6469
  if (elementorResult !== null) {
6478
6470
  return elementorResult;
6479
6471
  }
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) {
6472
+ // Check if this is an ACF tool.
6473
+ if (ACF_TOOL_NAMES.has(name)) {
6490
6474
  if (!(await this.isAcfAvailable())) {
6491
6475
  return {
6492
6476
  __respira_is_error: true,
@@ -6494,7 +6478,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6494
6478
  hint: 'Install Advanced Custom Fields on the target site, then retry. ACF Pro is required for repeaters, flexible content, galleries, and options pages.'
6495
6479
  };
6496
6480
  }
6497
- return await this.currentSite.dispatchAcfTool(acfName, args);
6481
+ return await this.currentSite.dispatchAcfTool(name, args);
6498
6482
  }
6499
6483
  // Check if this is a widget shortcut (wordpress_add_*).
6500
6484
  if (name.startsWith('wordpress_add_')) {
@@ -6602,20 +6586,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6602
6586
  type: 'number',
6603
6587
  description: 'Position index within the target container (0-based). Pass -1 to append.',
6604
6588
  },
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
6589
  },
6620
6590
  required: ['post_id', 'identifier_type', 'identifier_value', 'target_container_path', 'position'],
6621
6591
  },
@@ -6653,27 +6623,13 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6653
6623
  post_id: { type: 'number', description: 'Page/post ID' },
6654
6624
  identifier_type: {
6655
6625
  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.',
6626
+ enum: ['id', 'css_class', 'text', 'widget_type', 'global_id'],
6627
+ 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
6628
  },
6659
6629
  identifier_value: {
6660
6630
  type: 'string',
6661
6631
  description: 'Value matching the chosen identifier_type (e.g. the element ID, the class name, the text to match).',
6662
6632
  },
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
6633
  },
6678
6634
  required: ['post_id', 'identifier_type', 'identifier_value'],
6679
6635
  },
@@ -6723,20 +6679,6 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
6723
6679
  description: 'Array of element IDs in the desired order.',
6724
6680
  items: { type: 'string' },
6725
6681
  },
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
6682
  },
6741
6683
  required: ['post_id', 'container_path', 'new_order'],
6742
6684
  },