@respira/wordpress-mcp-server 6.18.6 → 6.19.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.
package/dist/server.js CHANGED
@@ -7,8 +7,9 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { execSync } from 'child_process';
10
- import { readFileSync } from 'fs';
11
- import { dirname, resolve } from 'path';
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
11
+ import { homedir } from 'os';
12
+ import { dirname, join, resolve } from 'path';
12
13
  import { fileURLToPath } from 'url';
13
14
  import { WordPressClient } from './wordpress-client.js';
14
15
  import { RespiraVersionChecker } from './version-checker.js';
@@ -25,6 +26,49 @@ import { getUsageEmitter } from './usage-emitter.js';
25
26
  * 6.11.4 release and never tracked subsequent npm publishes. Mirrors
26
27
  * the existing MCP_CLIENT_VERSION helper in wordpress-client.ts.
27
28
  */
29
+ /**
30
+ * B-19 (v6.18.7): persist the last active site to disk so the MCP process
31
+ * restoring across restarts (e.g. `claude_desktop_config.json` reload,
32
+ * `npx -y @respira/wordpress-mcp-server@latest` cache rebuild, OS reboot)
33
+ * doesn't always snap back to the default site. Reported by A.D. as the
34
+ * single biggest day-to-day friction across sessions on 2026-05-23.
35
+ *
36
+ * State lives in `~/.respira/state.json`. Single small object so we can
37
+ * extend it without a schema migration; only `last_active_site_id` is
38
+ * read today. Best-effort: any IO failure logs to stderr and falls back
39
+ * to the configured default site (the pre-v6.18.7 behaviour).
40
+ */
41
+ const STATE_DIR_PATH = join(homedir(), '.respira');
42
+ const STATE_FILE_PATH = join(STATE_DIR_PATH, 'state.json');
43
+ function loadRespiraMcpState() {
44
+ try {
45
+ if (!existsSync(STATE_FILE_PATH)) {
46
+ return {};
47
+ }
48
+ const raw = readFileSync(STATE_FILE_PATH, 'utf8');
49
+ const parsed = JSON.parse(raw);
50
+ if (parsed && typeof parsed === 'object') {
51
+ return parsed;
52
+ }
53
+ }
54
+ catch (err) {
55
+ console.error('[respira-mcp] could not read', STATE_FILE_PATH, '-', err?.message);
56
+ }
57
+ return {};
58
+ }
59
+ function saveRespiraMcpState(patch) {
60
+ try {
61
+ if (!existsSync(STATE_DIR_PATH)) {
62
+ mkdirSync(STATE_DIR_PATH, { recursive: true, mode: 0o700 });
63
+ }
64
+ const current = loadRespiraMcpState();
65
+ const next = { ...current, ...patch, schema_version: 1 };
66
+ writeFileSync(STATE_FILE_PATH, JSON.stringify(next, null, 2), { encoding: 'utf8', mode: 0o600 });
67
+ }
68
+ catch (err) {
69
+ console.error('[respira-mcp] could not write', STATE_FILE_PATH, '-', err?.message);
70
+ }
71
+ }
28
72
  const MCP_SERVER_VERSION = (() => {
29
73
  try {
30
74
  const currentDir = dirname(fileURLToPath(import.meta.url));
@@ -487,12 +531,42 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
487
531
  }
488
532
  }
489
533
  });
534
+ // B-19 (v6.18.7): restore the persisted last_active_site_id if present
535
+ // and the site is still known + allowed. Falls back silently to the
536
+ // default site picked above when the persisted id is stale (the
537
+ // customer renamed sites between client versions, hit by A.D.).
538
+ const persisted = loadRespiraMcpState();
539
+ if (persisted.last_active_site_id) {
540
+ const restored = this.sites.get(persisted.last_active_site_id);
541
+ if (restored && this.isSiteAllowed(restored)) {
542
+ this.currentSite = restored;
543
+ }
544
+ }
490
545
  this.setupHandlers();
491
546
  }
492
547
  getSiteSummary(site) {
493
548
  return site.getSiteSummary(site.getSiteId() === this.defaultSiteId);
494
549
  }
495
- getActiveSiteSummary() {
550
+ /**
551
+ * N6 fix (v6.19.0): the response envelope `site` field must reflect the
552
+ * client that ACTUALLY serviced the call, not the global default. Pre-fix,
553
+ * every tool wrapped its response with `site: this.currentSite.summary` —
554
+ * so a call with `site_id: "mihai-love"` against a default of `cekt-ro`
555
+ * ran correctly but reported `site: cekt-ro` in the envelope, breaking
556
+ * Cowork multi-tab parallel sessions that branched on the response site
557
+ * field. Now: if args carries `site_id` and it resolves to a known site,
558
+ * return THAT site's summary. Otherwise fall back to global `currentSite`.
559
+ */
560
+ getActiveSiteSummary(args) {
561
+ const overrideId = args && typeof args === 'object' && args !== null && typeof args.site_id === 'string'
562
+ ? args.site_id
563
+ : null;
564
+ if (overrideId) {
565
+ const overrideSite = this.sites.get(overrideId);
566
+ if (overrideSite) {
567
+ return this.getSiteSummary(overrideSite);
568
+ }
569
+ }
496
570
  if (!this.currentSite) {
497
571
  return null;
498
572
  }
@@ -854,8 +928,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
854
928
  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.`,
855
929
  };
856
930
  }
857
- withSiteContext(result) {
858
- const site = this.getActiveSiteSummary();
931
+ withSiteContext(result, args) {
932
+ const site = this.getActiveSiteSummary(args);
859
933
  if (!site) {
860
934
  return result;
861
935
  }
@@ -887,7 +961,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
887
961
  // respira_* alias to wordpress_* before dispatch.
888
962
  const canonical = this.normalizeToolName(name).canonical;
889
963
  if (canonical === 'wordpress_redeem_token') {
890
- return this.withSiteContext(await this.redeemInstallToken(String(args?.token || '')));
964
+ return this.withSiteContext(await this.redeemInstallToken(String(args?.token || '')), args);
891
965
  }
892
966
  if (!this.currentSite) {
893
967
  throw new Error('No WordPress site configured');
@@ -915,7 +989,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
915
989
  // Handle soft errors (e.g. unknown tool) — return isError without throwing.
916
990
  if (result && result.__respira_is_error) {
917
991
  const { __respira_is_error: _, ...errorPayload } = result;
918
- const activeSite = this.getActiveSiteSummary();
992
+ const activeSite = this.getActiveSiteSummary(args);
919
993
  return {
920
994
  content: [
921
995
  {
@@ -926,7 +1000,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
926
1000
  isError: true,
927
1001
  };
928
1002
  }
929
- const resultWithSite = this.withSiteContext(result);
1003
+ const resultWithSite = this.withSiteContext(result, args);
930
1004
  const resultWithNotice = await this.attachUpdateNotice(resultWithSite);
931
1005
  const resultWithVersionWarning = this.attachVersionWarning(resultWithNotice);
932
1006
  return {
@@ -946,7 +1020,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
946
1020
  }
947
1021
  this.currentSite?.setCurrentToolName(null);
948
1022
  if (error instanceof ToolTimeoutError) {
949
- const activeSite = this.getActiveSiteSummary();
1023
+ const activeSite = this.getActiveSiteSummary(args);
950
1024
  return {
951
1025
  content: [
952
1026
  {
@@ -1037,7 +1111,17 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1037
1111
  }
1038
1112
  }
1039
1113
  const errorWithUpdateNotice = await this.appendUpdateNoticeToError(errorMessage);
1040
- const activeSite = this.getActiveSiteSummary();
1114
+ const activeSite = this.getActiveSiteSummary(args);
1115
+ // B-17 (v6.18.7): gate the JS stack trace behind RESPIRA_DEBUG.
1116
+ // Pre-v6.18.7 every error response carried error.stack with full
1117
+ // `file:///opt/homebrew/lib/node_modules/.../wordpress-client.js:NNN`
1118
+ // paths, leaking the operator's local filesystem layout to whoever
1119
+ // collects the MCP response. The stack is only useful when actively
1120
+ // debugging the MCP server itself; production should not see it.
1121
+ const debugEnabled = process.env.RESPIRA_DEBUG === '1' ||
1122
+ process.env.RESPIRA_DEBUG === 'true' ||
1123
+ process.env.NODE_ENV === 'development';
1124
+ const stack = debugEnabled && error?.stack ? error.stack : undefined;
1041
1125
  return {
1042
1126
  content: [
1043
1127
  {
@@ -1045,7 +1129,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1045
1129
  text: JSON.stringify({
1046
1130
  error: errorWithUpdateNotice,
1047
1131
  site: activeSite,
1048
- stack: error?.stack || undefined,
1132
+ stack,
1049
1133
  // v6.17: every error response carries the report path so the
1050
1134
  // agent has a clear next step when retries + diagnose haven't
1051
1135
  // resolved the issue. The agent should ask the user before
@@ -1144,6 +1228,90 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1144
1228
  },
1145
1229
  readOnlyHint: true,
1146
1230
  },
1231
+ {
1232
+ name: 'wordpress_get_divi_migration_readiness',
1233
+ description: 'Generate a Divi 5 migration readiness report for the active WordPress site. ' +
1234
+ 'Counts Divi 4 vs Divi 5 vs mixed pages, flags high-risk pages (compatibility-mode pages, deprecated shortcodes), surfaces migration candidates, and tallies unknown shortcodes that may need manual handling. ' +
1235
+ 'Call this when the user asks "is my site ready for Divi 5", "should i migrate to Divi 5", "what would break if i migrated", or any variant. ' +
1236
+ 'Returns structured JSON (not prose) — the agent shapes it into a readable answer for the user. ' +
1237
+ 'Includes a disclaimer line that must be surfaced verbatim: Respira plans + validates; Divi\'s own Migrator performs the conversion. ' +
1238
+ 'Report is cached per-user for 24h.',
1239
+ inputSchema: {
1240
+ type: 'object',
1241
+ properties: {},
1242
+ },
1243
+ readOnlyHint: true,
1244
+ },
1245
+ {
1246
+ name: 'wordpress_abilities_gap_report',
1247
+ description: 'Compare the active plugins on this WordPress site against the curated list of plugins known to expose abilities via the WordPress Abilities API (wp_register_ability). ' +
1248
+ 'Returns two lists: (1) installed plugins that have shipped Abilities API support — Respira can wrap them as MCP tools through the Inhale gateway; (2) installed plugins that have not yet adopted the standard — each with a wp.org support URL where the user can file a feature request. ' +
1249
+ 'Call this when the user asks "what abilities could my site expose", "which of my plugins support AI", "what AI surface does this site have", "what plugins should i ask to adopt the Abilities API". ' +
1250
+ 'Returns structured JSON; the agent shapes it into a readable summary for the user.',
1251
+ inputSchema: {
1252
+ type: 'object',
1253
+ properties: {},
1254
+ },
1255
+ readOnlyHint: true,
1256
+ },
1257
+ {
1258
+ name: 'wordpress_search_abilities',
1259
+ description: 'SEARCH the curated WordPress abilities directory across all known plugins (Elementor, Yoast SEO, WooCommerce, Jetpack, ACF, Akismet, plus Respira\'s own 151 tools — 163 total today, growing weekly via auto-pull). ' +
1260
+ 'Use this whenever the user asks "is there an ability that does X", "can WordPress do Y", "what plugins expose Z as MCP", "how do i automate W on my site". ' +
1261
+ 'Each result is enriched with per-site context: is_installed (is the plugin active on THIS site), is_inhaled (has the admin opted the ability into Respira\'s MCP surface), install_url (wp.org page when not installed), inhale_admin_url (Respira admin tab when installed but not inhaled), and how_to_invoke (the exact tool call to make when ready). ' +
1262
+ 'After finding a matching ability, if is_inhaled is true you can call it via wordpress_invoke_ability. Otherwise surface install_url or inhale_admin_url to the user so they can opt in. ' +
1263
+ 'No auth gates: this surface is open to every agent connected to Respira. The directory itself is global; the enrichment is per-site.',
1264
+ inputSchema: {
1265
+ type: 'object',
1266
+ properties: {
1267
+ q: {
1268
+ type: 'string',
1269
+ description: 'Free-text query — matches against ability_name, label, description, plugin_name, category. Optional; an empty query returns the top of the directory.',
1270
+ },
1271
+ plugin: {
1272
+ type: 'string',
1273
+ description: 'Restrict to a single plugin by wp.org slug (e.g. "wordpress-seo") or display name ("Yoast SEO"). Optional.',
1274
+ },
1275
+ category: {
1276
+ type: 'string',
1277
+ description: 'Restrict to a single category (e.g. "SEO", "E-commerce", "Page Builder", "Custom Fields"). Optional.',
1278
+ },
1279
+ kind: {
1280
+ type: 'string',
1281
+ enum: ['read', 'write'],
1282
+ description: 'Restrict to read-only or write abilities. Optional.',
1283
+ },
1284
+ limit: {
1285
+ type: 'integer',
1286
+ description: 'Max results to return (default 20, max 100).',
1287
+ default: 20,
1288
+ },
1289
+ },
1290
+ },
1291
+ readOnlyHint: true,
1292
+ },
1293
+ {
1294
+ name: 'wordpress_invoke_ability',
1295
+ description: 'Invoke an inhaled WordPress Abilities API ability through the Respira safety wrapper. ' +
1296
+ 'The wrapper snapshots the target post if the call looks like a write, runs the underlying ability, logs the call to the audit trail, and returns a structured envelope with a rollback URL when a snapshot was taken. ' +
1297
+ 'The ability must (a) be registered on the site via wp_register_ability, AND (b) be inhaled (the admin toggled it on at Respira > MCP Abilities). Otherwise the call returns a structured 403/404 with instructions for the user. ' +
1298
+ 'Use this to call any Yoast / Elementor / WooCommerce / ACF / Jetpack ability — anything the site has inhaled — with Respira\'s snapshot-before-write protection layered on top. ' +
1299
+ 'Pass ability="vendor/ability-name" and args={…}. The args shape comes from the underlying ability\'s own schema; consult its documentation.',
1300
+ inputSchema: {
1301
+ type: 'object',
1302
+ properties: {
1303
+ ability: {
1304
+ type: 'string',
1305
+ description: 'Full ability name as registered, e.g. "yoast-seo/analyze-post" or "elementor/get-widget".',
1306
+ },
1307
+ args: {
1308
+ type: 'object',
1309
+ description: 'Arguments to forward to the ability\'s execute handler. Shape varies per ability.',
1310
+ },
1311
+ },
1312
+ required: ['ability'],
1313
+ },
1314
+ },
1147
1315
  {
1148
1316
  name: 'wordpress_get_active_site',
1149
1317
  description: 'Return the currently active site in the Respira multi-site configuration.',
@@ -1297,7 +1465,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1297
1465
  },
1298
1466
  {
1299
1467
  name: 'wordpress_delete_page',
1300
- description: 'Delete a page. IMPORTANT: By default, this only works on Respira-created duplicates. The force parameter only works if "Allow Direct Editing" is enabled in Respira settings (disabled by default for safety).\n\nApproval flow (added v6.14.2): the first call returns `code: respira_approval_required` with `data.approval_request.approval_token`. Pass that token back via the `approval_token` param on the next call to complete the delete. Pre-v6.14.2 the schema did not expose `approval_token` so agents could see the token in the response but had no way to pass it back — leaving every agent-driven cleanup of its own duplicates stuck.',
1468
+ description: 'Delete a page. IMPORTANT: By default, this only works on Respira-created duplicates. To delete an original: pass `force=true` AND `confirm_live_edit=true`. The "Allow Direct Editing" setting in Respira must also be enabled (disabled by default for safety).\n\nApproval flow (added v6.14.2): the first call returns `code: respira_approval_required` with `data.approval_request.approval_token`. Pass that token back via the `approval_token` param on the next call to complete the delete. Pre-v6.14.2 the schema did not expose `approval_token` so agents could see the token in the response but had no way to pass it back — leaving every agent-driven cleanup of its own duplicates stuck.',
1301
1469
  inputSchema: {
1302
1470
  type: 'object',
1303
1471
  properties: {
@@ -1307,7 +1475,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1307
1475
  },
1308
1476
  force: {
1309
1477
  type: 'boolean',
1310
- description: 'Force delete even if not a duplicate',
1478
+ description: 'Force delete even if not a duplicate. Requires confirm_live_edit=true.',
1479
+ },
1480
+ confirm_live_edit: {
1481
+ type: 'boolean',
1482
+ description: 'N37 fix (v6.19.0): operator confirmation that a force-delete on an original is intended. The server has always required this alongside force=true; pre-v6.19.0 it was undocumented and the surfaced approval response leaked it as a "surreptitious" field. Now in the schema.',
1311
1483
  },
1312
1484
  approval_token: {
1313
1485
  type: 'string',
@@ -1385,6 +1557,33 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1385
1557
  required: ['original_id'],
1386
1558
  },
1387
1559
  },
1560
+ {
1561
+ name: 'respira_duplicate_with_translations',
1562
+ description: 'Duplicate a WPML-linked page or post together with all its language translations into a new independent translation group. Use this when a site uses WPML and you need a safe copy of a multilingual page (e.g. an EN+RO report page) to edit without touching the original. Each language copy is duplicated, WPML metadata is cleared, and all duplicates are re-linked under a new translation group. Returns a map of language_code → {original_id, duplicate_id, url}. On non-WPML sites falls back to a single-language duplicate. Call respira_get_site_context first to check whether WPML is active.',
1563
+ inputSchema: {
1564
+ type: 'object',
1565
+ properties: {
1566
+ post_id: {
1567
+ type: 'number',
1568
+ description: 'ID of any page/post in the translation group (can be any language version).',
1569
+ },
1570
+ new_titles: {
1571
+ type: 'object',
1572
+ description: 'Optional map of language_code to title string for the new duplicates. Example: {"en": "Reports - Draft", "ro": "Rapoarte - Ciornă"}. When omitted, the original titles are kept.',
1573
+ additionalProperties: { type: 'string' },
1574
+ },
1575
+ suffix: {
1576
+ type: 'string',
1577
+ description: 'Optional suffix used internally to label the duplicate (not appended to the title unless new_titles is also omitted). Defaults to a timestamp.',
1578
+ },
1579
+ type: {
1580
+ type: 'string',
1581
+ description: 'Post type slug: "page" (default), "post", or a custom post type.',
1582
+ },
1583
+ },
1584
+ required: ['post_id'],
1585
+ },
1586
+ },
1388
1587
  {
1389
1588
  name: 'wordpress_update_post',
1390
1589
  description: 'Update a post. Supports author reassignment, taxonomy terms (categories/tags/custom), and featured media. When direct editing is enabled and you target an original post, Respira returns a confirmation_required preflight by default so you can choose live/original or duplicate.',
@@ -1782,7 +1981,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1782
1981
  },
1783
1982
  {
1784
1983
  name: 'wordpress_get_snapshot',
1785
- description: 'Get one Respira v2 snapshot by UUID.',
1984
+ description: 'Get one Respira v2 snapshot by UUID. Defaults to metadata-only (kind, label, hashes, timestamps, actor). Pass `include=content` to fetch the full builder payload — that adds ~50-100KB per snapshot, so opt in only when you actually need the bytes (e.g. building a content-level diff, restoring partial data).',
1786
1985
  inputSchema: {
1787
1986
  type: 'object',
1788
1987
  properties: {
@@ -1790,6 +1989,12 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1790
1989
  type: 'string',
1791
1990
  description: 'Snapshot UUID',
1792
1991
  },
1992
+ // N26 fix (v6.19.0): `include` opt-in for the heavy fields.
1993
+ // Defaults to metadata-only to keep agent context tight.
1994
+ include: {
1995
+ type: 'string',
1996
+ description: 'CSV of optional sections to include in the response. Currently supports "content" (adds the full builder payload). Default: metadata-only.',
1997
+ },
1793
1998
  },
1794
1999
  required: ['snapshot_uuid'],
1795
2000
  },
@@ -1831,7 +2036,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1831
2036
  },
1832
2037
  {
1833
2038
  name: 'wordpress_apply_builder_patch',
1834
- description: 'Apply Respira v2 builder patch operations (identifier by id/path/type+match_content).',
2039
+ description: 'Apply a list of targeted builder patch operations. Each operation is { identifier: { id|admin_label|path|type [+match_content] }, updates: { content?, attributes?, ...flat_settings? } }. The identifier block follows the same shape as wordpress_find_element\'s identifier; the updates block follows the same shape as wordpress_update_module\'s updates. Returns 400 respira_patch_invalid_operation when an entry is missing either field. This is NOT a JSON-Patch document — do not pass { op, path, value } shapes (those will be rejected). Example: { operations: [{ identifier: { admin_label: "Hero" }, updates: { admin_label: "Hero Content" } }] }.',
1835
2040
  inputSchema: {
1836
2041
  type: 'object',
1837
2042
  properties: {
@@ -1849,8 +2054,28 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
1849
2054
  },
1850
2055
  operations: {
1851
2056
  type: 'array',
1852
- description: 'Patch operations[]',
1853
- items: { type: 'object' },
2057
+ description: 'List of patch operations applied in order.',
2058
+ items: {
2059
+ type: 'object',
2060
+ required: ['identifier', 'updates'],
2061
+ properties: {
2062
+ identifier: {
2063
+ type: 'object',
2064
+ description: 'Element identifier. Provide exactly one of id / admin_label / path / type. match_content disambiguates duplicates of the same type.',
2065
+ properties: {
2066
+ id: { type: 'string', description: 'Builder element ID (Divi 5 _nodeId, Elementor element id, Bricks id, etc).' },
2067
+ admin_label: { type: 'string', description: 'Admin label / navigator label.' },
2068
+ path: { description: 'Index path as either a dot-string "0.1.2" or an array [0,1,2].' },
2069
+ type: { type: 'string', description: 'Module type (e.g. "divi/heading", "et_pb_text", "core/paragraph").' },
2070
+ match_content: { type: 'string', description: 'Optional content substring for disambiguation.' },
2071
+ },
2072
+ },
2073
+ updates: {
2074
+ type: 'object',
2075
+ description: 'Patch to merge into the matched element. Same shape as wordpress_update_module.updates: { content?: string, attributes?: object, ...flat_settings_keys? }. Flat keys (admin_label, background_color, font_size, ...) route through the adapter\'s deep-set helper.',
2076
+ },
2077
+ },
2078
+ },
1854
2079
  },
1855
2080
  edit_target: {
1856
2081
  type: 'string',
@@ -2059,7 +2284,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2059
2284
  },
2060
2285
  {
2061
2286
  name: 'wordpress_get_core_web_vitals',
2062
- description: 'Get Core Web Vitals metrics (LCP, FID, CLS) for a page.',
2287
+ description: 'Get Core Web Vitals metrics (LCP, FID, CLS) for a page.\n\n**Deprecated path** (v6.19.0+, Phase E Tier 1): the current implementation returns a HEURISTIC estimate from static page analysis, NOT real Lighthouse / CrUX data. The response includes `data_source: "respira_heuristic_v1"` and a `_deprecation` field. For real measurements use `respira_run_pagespeed_audit` (Phase E Tier 2).',
2063
2288
  inputSchema: {
2064
2289
  type: 'object',
2065
2290
  properties: {
@@ -2072,6 +2297,44 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2072
2297
  },
2073
2298
  readOnlyHint: true,
2074
2299
  },
2300
+ {
2301
+ // Phase E Tier 2 (v6.19.0): real PageSpeed Insights v5 audit. The
2302
+ // honest replacement for `get_core_web_vitals` heuristic. Returns
2303
+ // Lighthouse lab data + CrUX field data (when available) + the
2304
+ // opportunity list ranked by metric savings.
2305
+ name: 'wordpress_run_pagespeed_audit',
2306
+ description: 'Run a real PageSpeed Insights v5 audit against a public URL. Returns Lighthouse lab data (scores for performance/accessibility/best-practices/seo, lab metrics FCP/LCP/TBT/CLS/SI/TTI, opportunities ranked by savings_ms, diagnostics) plus CrUX field data (real-user p75 metrics over the trailing ~28 days, when Google has enough traffic to publish them for the URL). Cached for 1 hour per (url, strategy). Pass `fresh=true` to bypass the cache. `strategy="both"` runs mobile + desktop in series. Set the `respira_pagespeed_api_key` WordPress option to lift the 1 req/sec free-tier rate limit to 200 req/min.',
2307
+ inputSchema: {
2308
+ type: 'object',
2309
+ properties: {
2310
+ page_id: { type: 'number', description: 'Page ID to audit. Resolved to the public permalink server-side before the PSI call.' },
2311
+ url: { type: 'string', description: 'Optional explicit URL override. When provided, skips the page_id → permalink resolution. Use for staging / preview / partner-site URLs.' },
2312
+ strategy: { type: 'string', enum: ['mobile', 'desktop', 'both'], default: 'mobile', description: 'Audit profile. Default mobile.' },
2313
+ fresh: { type: 'boolean', default: false, description: 'Bypass the 1h transient cache.' },
2314
+ site_id: { type: 'string', description: 'Per-call site override.' },
2315
+ },
2316
+ },
2317
+ readOnlyHint: true,
2318
+ },
2319
+ {
2320
+ // Phase E Tier 2 (v6.19.0): standard-analyzer-envelope wrapper around
2321
+ // PSI. Feeds the Health tab composite via Respira_Site_Health's new
2322
+ // `pagespeed` slot. Use for periodic site-wide health passes; use
2323
+ // `respira_run_pagespeed_audit` for the raw lab+field data.
2324
+ name: 'wordpress_analyze_pagespeed',
2325
+ description: 'Analyzer-envelope wrapper around the PSI audit. Returns the standard `{success, score, grade, issues, recommendations, metrics, data_source, measured_at}` shape that the Reports → Health tab consumes. Score = Lighthouse performance score (0-100). Grade A≥90, B≥75, C≥60, D≥40, F<40. Issues derived from Lighthouse opportunities, ranked by savings_ms. CrUX p75 field-data surfaces as an info-priority recommendation when available.',
2326
+ inputSchema: {
2327
+ type: 'object',
2328
+ properties: {
2329
+ page_id: { type: 'number', description: 'Page ID to audit.' },
2330
+ url: { type: 'string', description: 'Optional explicit URL override.' },
2331
+ strategy: { type: 'string', enum: ['mobile', 'desktop'], default: 'mobile' },
2332
+ fresh: { type: 'boolean', default: false, description: 'Bypass the 1h cache.' },
2333
+ site_id: { type: 'string', description: 'Per-call site override.' },
2334
+ },
2335
+ },
2336
+ readOnlyHint: true,
2337
+ },
2075
2338
  {
2076
2339
  name: 'wordpress_analyze_images',
2077
2340
  description: 'Analyze image optimization opportunities including missing alt text, large files, and unoptimized formats.',
@@ -2182,10 +2445,14 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2182
2445
  // Accessibility tools
2183
2446
  {
2184
2447
  name: 'wordpress_list_accessibility_scans',
2185
- description: 'List previous accessibility scans with scores and violation counts.',
2448
+ description: 'List previous accessibility scans with scores and violation counts. Pass `summary_only=true` (default) for a compact response that omits violations[].nodes and the per-violation AI fix prompts — those add up to ~30KB per scan and blow past the MCP response cap with 5+ entries. Use `summary_only=false` only when you genuinely need the full payload.',
2186
2449
  inputSchema: {
2187
2450
  type: 'object',
2188
- properties: {},
2451
+ properties: {
2452
+ limit: { type: 'number', description: 'Max scans to return (default 20).' },
2453
+ offset: { type: 'number', description: 'Pagination offset (default 0).' },
2454
+ summary_only: { type: 'boolean', description: 'When true (default), strips violations[].nodes and prompts[] from each entry. The full payload remains available via respira_get_accessibility_scan.' },
2455
+ },
2189
2456
  },
2190
2457
  readOnlyHint: true,
2191
2458
  },
@@ -2195,7 +2462,8 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2195
2462
  inputSchema: {
2196
2463
  type: 'object',
2197
2464
  properties: {
2198
- scan_id: { type: 'number', description: 'Scan ID to retrieve' },
2465
+ // N15 fix (v6.19.0): scan_id is a UUID STRING (matches what list_accessibility_scans returns), not a number. Pre-fix the schema declared number and callers couldn't pair the two tools.
2466
+ scan_id: { type: 'string', description: 'Scan ID (UUID) to retrieve. Returned in id field of list_accessibility_scans entries.' },
2199
2467
  },
2200
2468
  required: ['scan_id'],
2201
2469
  },
@@ -2377,7 +2645,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2377
2645
  },
2378
2646
  {
2379
2647
  name: 'wordpress_create_user',
2380
- description: 'Create a new user.',
2648
+ description: 'Create a new user.\n\nApproval flow (N9 fix, v6.19.0): destructive — the first call returns `code: respira_approval_required` with `data.approval_request.approval_token`. Pass that token back via the `approval_token` param on the next call to complete the create. Pre-v6.19.0 the schema did not expose `approval_token` so agents could see the token in the response but had no way to complete the flow.',
2381
2649
  inputSchema: {
2382
2650
  type: 'object',
2383
2651
  properties: {
@@ -2397,13 +2665,17 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2397
2665
  type: 'string',
2398
2666
  description: 'User role (default: subscriber)',
2399
2667
  },
2668
+ approval_token: {
2669
+ type: 'string',
2670
+ description: 'Single-use approval token from a prior respira_approval_required response. The plugin returns a fresh token on every blocked call (data.approval_request.approval_token); pass that exact string here on the retry to complete the create.',
2671
+ },
2400
2672
  },
2401
2673
  required: ['username', 'email', 'password'],
2402
2674
  },
2403
2675
  },
2404
2676
  {
2405
2677
  name: 'wordpress_update_user',
2406
- description: 'Update user information.',
2678
+ description: 'Update user information.\n\nApproval flow (N9 fix, v6.19.0): destructive — first call returns `respira_approval_required` with `approval_token`; pass it back to complete the update.',
2407
2679
  inputSchema: {
2408
2680
  type: 'object',
2409
2681
  properties: {
@@ -2427,6 +2699,10 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2427
2699
  type: 'string',
2428
2700
  description: 'User role',
2429
2701
  },
2702
+ approval_token: {
2703
+ type: 'string',
2704
+ description: 'Single-use approval token from a prior respira_approval_required response. Pass the token from data.approval_request.approval_token to complete the update.',
2705
+ },
2430
2706
  },
2431
2707
  required: ['id'],
2432
2708
  },
@@ -2434,7 +2710,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2434
2710
  },
2435
2711
  {
2436
2712
  name: 'wordpress_delete_user',
2437
- description: 'Delete a user.',
2713
+ description: 'Delete a user.\n\nApproval flow (N9 fix, v6.19.0): destructive — first call returns `respira_approval_required` with `approval_token`; pass it back to complete the delete.',
2438
2714
  inputSchema: {
2439
2715
  type: 'object',
2440
2716
  properties: {
@@ -2446,6 +2722,10 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2446
2722
  type: 'number',
2447
2723
  description: 'User ID to reassign posts to (optional)',
2448
2724
  },
2725
+ approval_token: {
2726
+ type: 'string',
2727
+ description: 'Single-use approval token from a prior respira_approval_required response. Pass the token from data.approval_request.approval_token to complete the delete.',
2728
+ },
2449
2729
  },
2450
2730
  required: ['id'],
2451
2731
  },
@@ -2977,7 +3257,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2977
3257
  },
2978
3258
  {
2979
3259
  name: 'wordpress_delete_custom_post',
2980
- description: 'Delete a custom post.',
3260
+ description: 'Delete a custom post.\n\nSafety flow (N9 fix, v6.19.0): destructive. By default this only works on Respira-created duplicates. To delete an original: pass `force=true` AND `confirm_live_edit=true` (the latter requires "Allow Direct Editing" enabled in Respira settings). Approval gate may also fire — if response is `respira_approval_required`, pass the returned `approval_token` back to complete the delete. Pre-v6.19.0 the schema lacked `force`, `confirm_live_edit`, and `approval_token` so agents could not complete the flow at all.',
2981
3261
  inputSchema: {
2982
3262
  type: 'object',
2983
3263
  properties: {
@@ -2989,6 +3269,18 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
2989
3269
  type: 'number',
2990
3270
  description: 'Post ID',
2991
3271
  },
3272
+ force: {
3273
+ type: 'boolean',
3274
+ description: 'Force delete even if the post is an original (not a Respira-created duplicate). Requires confirm_live_edit=true as well.',
3275
+ },
3276
+ confirm_live_edit: {
3277
+ type: 'boolean',
3278
+ description: 'Operator confirmation that a live-edit on an original is intended. Required alongside force=true. The "Allow Direct Editing" setting in Respira must also be enabled.',
3279
+ },
3280
+ approval_token: {
3281
+ type: 'string',
3282
+ description: 'Single-use approval token from a prior respira_approval_required response. Pass the token from data.approval_request.approval_token to complete the delete.',
3283
+ },
2992
3284
  },
2993
3285
  required: ['type', 'id'],
2994
3286
  },
@@ -3207,7 +3499,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
3207
3499
  },
3208
3500
  {
3209
3501
  name: 'wordpress_delete_menu',
3210
- description: 'Delete a navigation menu.',
3502
+ description: 'Delete a navigation menu.\n\nApproval flow (N9 fix, v6.19.0): destructive — first call returns `respira_approval_required` with `approval_token`; pass it back to complete the delete.',
3211
3503
  inputSchema: {
3212
3504
  type: 'object',
3213
3505
  properties: {
@@ -3215,6 +3507,10 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
3215
3507
  type: 'number',
3216
3508
  description: 'Menu ID',
3217
3509
  },
3510
+ approval_token: {
3511
+ type: 'string',
3512
+ description: 'Single-use approval token from a prior respira_approval_required response. Pass the token from data.approval_request.approval_token to complete the delete.',
3513
+ },
3218
3514
  },
3219
3515
  required: ['id'],
3220
3516
  },
@@ -3925,8 +4221,18 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
3925
4221
  const errorCode = dispatchError instanceof Error
3926
4222
  ? dispatchError.name.slice(0, 80)
3927
4223
  : dispatchError != null ? 'unknown_error' : null;
4224
+ // v7.1: when an agent calls the universal ability proxy
4225
+ // (wordpress_invoke_ability), record the call under the underlying
4226
+ // ability name (`inhale_ability:<vendor>/<name>`) so the public
4227
+ // /abilities marketplace page can group quality metrics per
4228
+ // ability, not per proxy. The proxy itself stays a single MCP tool
4229
+ // from the agent's perspective.
4230
+ let telemetryToolName = canonical;
4231
+ if (canonical === 'wordpress_invoke_ability' && typeof args?.ability === 'string' && args.ability.includes('/')) {
4232
+ telemetryToolName = `inhale_ability:${args.ability}`;
4233
+ }
3928
4234
  emitter.record({
3929
- toolName: canonical,
4235
+ toolName: telemetryToolName,
3930
4236
  siteUrl,
3931
4237
  durationMs: Date.now() - startedAt,
3932
4238
  success: dispatchError === null,
@@ -4040,6 +4346,20 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4040
4346
  return await client.getThemeDocs();
4041
4347
  case 'wordpress_get_builder_info':
4042
4348
  return await client.getBuilderInfo({ debug: Boolean(args?.debug) });
4349
+ case 'wordpress_get_divi_migration_readiness':
4350
+ return await client.getDiviMigrationReadiness();
4351
+ case 'wordpress_abilities_gap_report':
4352
+ return await client.getAbilitiesGapReport();
4353
+ case 'wordpress_search_abilities':
4354
+ return await client.searchAbilities({
4355
+ q: args?.q,
4356
+ plugin: args?.plugin,
4357
+ category: args?.category,
4358
+ kind: args?.kind,
4359
+ limit: args?.limit,
4360
+ });
4361
+ case 'wordpress_invoke_ability':
4362
+ return await client.invokeInhaledAbility(args?.ability, args?.args || {});
4043
4363
  case 'wordpress_list_pages':
4044
4364
  return await client.listPages(args);
4045
4365
  case 'wordpress_read_page':
@@ -4082,6 +4402,13 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4082
4402
  ...(await client.duplicatePost(args.original_id, args.suffix, args.include)),
4083
4403
  respira_approvals_url: client.getApprovalsUrl(),
4084
4404
  };
4405
+ case 'respira_duplicate_with_translations':
4406
+ return await client.duplicateWithTranslations({
4407
+ post_id: args.post_id,
4408
+ new_titles: args.new_titles ?? null,
4409
+ suffix: args.suffix ?? null,
4410
+ type: args.type ?? 'page',
4411
+ });
4085
4412
  case 'wordpress_update_post': {
4086
4413
  const approvalsUrl = client.getApprovalsUrl();
4087
4414
  const post = await client.updatePost(args.id, args);
@@ -4152,6 +4479,9 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4152
4479
  }
4153
4480
  this.currentSite = newSite;
4154
4481
  this.cachedFilterContext = null; // Invalidate tool filter cache on site switch.
4482
+ // B-19 (v6.18.7): persist so the next process restart restores
4483
+ // this site instead of falling back to the configured default.
4484
+ saveRespiraMcpState({ last_active_site_id: String(args.site_id) });
4155
4485
  return {
4156
4486
  success: true,
4157
4487
  message: `Switched to site: ${newSite.getSiteName()}`,
@@ -4206,6 +4536,20 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4206
4536
  return await client.analyzePerformance(args.page_id);
4207
4537
  case 'wordpress_get_core_web_vitals':
4208
4538
  return await client.getCoreWebVitals(args.page_id);
4539
+ case 'wordpress_run_pagespeed_audit':
4540
+ return await client.runPageSpeedAudit({
4541
+ page_id: args.page_id,
4542
+ url: args.url,
4543
+ strategy: args.strategy,
4544
+ fresh: args.fresh,
4545
+ });
4546
+ case 'wordpress_analyze_pagespeed':
4547
+ return await client.analyzePagespeed({
4548
+ page_id: args.page_id,
4549
+ url: args.url,
4550
+ strategy: args.strategy,
4551
+ fresh: args.fresh,
4552
+ });
4209
4553
  case 'wordpress_analyze_images':
4210
4554
  return await client.analyzeImages(args.page_id);
4211
4555
  // SEO Analysis
@@ -4224,7 +4568,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4224
4568
  return await client.checkStructuredData(args.page_id);
4225
4569
  // Accessibility
4226
4570
  case 'wordpress_list_accessibility_scans':
4227
- return await client.listAccessibilityScans();
4571
+ return await client.listAccessibilityScans({
4572
+ limit: args.limit,
4573
+ offset: args.offset,
4574
+ summary_only: args.summary_only,
4575
+ });
4228
4576
  case 'wordpress_get_accessibility_scan':
4229
4577
  return await client.getAccessibilityScan(args.scan_id);
4230
4578
  case 'wordpress_scan_page_accessibility':
@@ -4254,7 +4602,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4254
4602
  case 'wordpress_update_user':
4255
4603
  return await client.updateUser(args.id, args);
4256
4604
  case 'wordpress_delete_user':
4257
- return await client.deleteUser(args.id, args.reassign);
4605
+ return await client.deleteUser(args.id, args.reassign, args.approval_token);
4258
4606
  // Comments
4259
4607
  case 'wordpress_list_comments':
4260
4608
  return await client.listComments(args);
@@ -4295,7 +4643,11 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4295
4643
  case 'wordpress_update_custom_post':
4296
4644
  return await client.updateCustomPost(args.type, args.id, args);
4297
4645
  case 'wordpress_delete_custom_post':
4298
- return await client.deleteCustomPost(args.type, args.id);
4646
+ return await client.deleteCustomPost(args.type, args.id, {
4647
+ force: args.force,
4648
+ confirm_live_edit: args.confirm_live_edit,
4649
+ approval_token: args.approval_token,
4650
+ });
4299
4651
  // Options
4300
4652
  case 'wordpress_list_options':
4301
4653
  return await client.listOptions(args.search);
@@ -4324,7 +4676,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4324
4676
  case 'wordpress_update_menu':
4325
4677
  return await client.updateMenu(args.id, args);
4326
4678
  case 'wordpress_delete_menu':
4327
- return await client.deleteMenu(args.id);
4679
+ return await client.deleteMenu(args.id, args.approval_token);
4328
4680
  // Menu Locations
4329
4681
  case 'wordpress_list_menu_locations':
4330
4682
  return await client.listMenuLocations();
@@ -4344,7 +4696,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4344
4696
  case 'wordpress_list_snapshots':
4345
4697
  return await client.listSnapshots(args);
4346
4698
  case 'wordpress_get_snapshot':
4347
- return await client.getSnapshot(args.snapshot_uuid);
4699
+ return await client.getSnapshot(args.snapshot_uuid, args.include);
4348
4700
  case 'wordpress_diff_snapshots':
4349
4701
  return await client.diffSnapshots(args.snapshot_uuid_a, args.snapshot_uuid_b);
4350
4702
  case 'wordpress_restore_snapshot':
@@ -4493,7 +4845,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4493
4845
  // --- Element Operations ---
4494
4846
  {
4495
4847
  name: 'wordpress_find_element',
4496
- description: 'Find an element in a page by ID, type, CSS class, or text content. This is the PRIMARY tool for locating content to edit — use it instead of searching the database or reading PHP files. Works with all 12 page builders. Use identifier_type "content" to search by visible text (e.g. find a heading containing "2025" to update a year). Returns matching element(s) with their position, settings, and element ID for use with update_element.',
4848
+ description: 'Find an element in a page by ID, type, CSS class, text content, or uncode_shortcode_id. This is the PRIMARY tool for locating content to edit — use it instead of searching the database or reading PHP files. Works with all 12 page builders. Use identifier_type "content" to search by visible text (e.g. find a heading containing "2025" to update a year). On WPBakery + Uncode pages, prefer identifier_type "uncode_shortcode_id" — the id is stable across saves and theme updates while class lists and admin labels drift. Returns matching element(s) with their position, settings, and element ID for use with update_element.',
4497
4849
  inputSchema: {
4498
4850
  type: 'object',
4499
4851
  properties: {
@@ -4501,7 +4853,7 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4501
4853
  identifier_type: {
4502
4854
  type: 'string',
4503
4855
  description: 'How to find the element',
4504
- enum: ['id', 'type', 'class', 'content'],
4856
+ enum: ['id', 'type', 'class', 'content', 'uncode_shortcode_id'],
4505
4857
  },
4506
4858
  identifier_value: { type: 'string', description: 'Value to search for' },
4507
4859
  match_content: {
@@ -4515,14 +4867,14 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4515
4867
  },
4516
4868
  {
4517
4869
  name: 'wordpress_update_element',
4518
- description: 'Update settings or content on a specific element in a page. This is the PRIMARY tool for making content changes — use it for text edits, style changes, image swaps, link updates, etc. Works with all 12 page builders. First use find_element to locate the element, then pass the same identifier here with the updates object containing the new values. Response includes target_id, original_id, edit_target ("live" or "duplicate"), is_duplicate, duplicate_created and post_status so the caller can never mistake a duplicate-routed write for a live-page change.',
4870
+ description: 'Update settings or content on a specific element in a page. This is the PRIMARY tool for making content changes — use it for text edits, style changes, image swaps, link updates, etc. Works with all 12 page builders. First use find_element to locate the element, then pass the same identifier here with the updates object containing the new values. On WPBakery + Uncode pages, prefer identifier_type "uncode_shortcode_id" for stable round-trip matching. Response includes target_id, original_id, edit_target ("live" or "duplicate"), is_duplicate, duplicate_created and post_status so the caller can never mistake a duplicate-routed write for a live-page change.',
4519
4871
  inputSchema: {
4520
4872
  type: 'object',
4521
4873
  properties: {
4522
4874
  post_id: { type: 'number', description: 'Page/post ID' },
4523
4875
  identifier_type: {
4524
4876
  type: 'string',
4525
- enum: ['id', 'type', 'class', 'content'],
4877
+ enum: ['id', 'type', 'class', 'content', 'uncode_shortcode_id'],
4526
4878
  },
4527
4879
  identifier_value: { type: 'string', description: 'Value to match' },
4528
4880
  updates: {
@@ -4593,15 +4945,27 @@ Allowlist: css, scss, less, json. PHP / JS theme writes are intentionally out of
4593
4945
  },
4594
4946
  {
4595
4947
  name: 'wordpress_batch_update',
4596
- description: 'Apply multiple element operations to a page in a single atomic transaction. Extracts content once, applies all operations, and injects once.',
4948
+ description: 'Apply multiple element operations to a page in a single atomic transaction. Extracts content once, applies all operations, and injects once. Per-operation identifier shape: {identifier_type, identifier_value} (NOT {identifier: {...}} — that\'s respira_apply_builder_patch\'s shape). Use this when you have a list of updates to apply against existing elements; use respira_apply_builder_patch when you have a patch-document with a richer identifier block.',
4597
4949
  inputSchema: {
4598
4950
  type: 'object',
4599
4951
  properties: {
4600
4952
  post_id: { type: 'number', description: 'Page/post ID' },
4601
4953
  operations: {
4602
4954
  type: 'array',
4603
- description: 'Array of operations: [{action: "update"|"remove"|"move"|"duplicate", ...params}]',
4604
- items: { type: 'object' },
4955
+ description: 'Array of operations applied in order. Each op: {action: "update"|"remove"|"move"|"duplicate", identifier_type: "id"|"admin_label"|"type"|"path", identifier_value: string, updates?: object, target_container_path?: string, position?: number, match_content?: string}.',
4956
+ items: {
4957
+ type: 'object',
4958
+ required: ['action', 'identifier_type', 'identifier_value'],
4959
+ properties: {
4960
+ action: { type: 'string', enum: ['update', 'remove', 'move', 'duplicate'] },
4961
+ identifier_type: { type: 'string', enum: ['id', 'admin_label', 'type', 'widget_type', 'path', 'content'] },
4962
+ identifier_value: { type: 'string' },
4963
+ updates: { type: 'object', description: 'For action=update: flat settings patch OR nested attrs bucket. See respira_update_element for shape.' },
4964
+ target_container_path: { type: 'string', description: 'For action=move: destination container path.' },
4965
+ position: { type: 'number', description: 'For action=move: index inside the target container.' },
4966
+ match_content: { type: 'string', description: 'Optional disambiguator for type-based identifiers when the same type appears multiple times on the page.' },
4967
+ },
4968
+ },
4605
4969
  },
4606
4970
  },
4607
4971
  required: ['post_id', 'operations'],