@diviops/mcp-server 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
13
13
  import { z } from "zod";
14
14
  import { WPClient } from "./wp-client.js";
15
15
  import { MissingCapabilityError } from "./compatibility.js";
16
+ import { ErrorCodes, envelopeMap, serializeEnvelope, withCode, wrapResponse, } from "./envelope.js";
16
17
  import { optimizeSchema } from "./schema-optimizer.js";
17
18
  import { createWpCli } from "./wp-cli.js";
18
19
  import { findForeignVarRefs, scanAttrsForForeignVarRefs, isolationErrorResult, } from "./validate-attrs.js";
@@ -124,9 +125,28 @@ function registerPluginTool(name, config, handler) {
124
125
  server.registerTool(name, config, wrapped);
125
126
  }
126
127
  /* eslint-enable @typescript-eslint/no-explicit-any */
128
+ // ── dry_run convention ──────────────────────────────────────────────
129
+ //
130
+ // Standard description suffix appended to every write tool that accepts
131
+ // dry_run, and a shared Zod field reused across the registrations. The
132
+ // suffix lets the model see one consistent line per tool ("Pass dry_run:
133
+ // true to preview the change plan without mutating state."), and the
134
+ // shared field guarantees the same default + description across the
135
+ // surface.
136
+ //
137
+ // Shape returned when dry_run is true (built by the plugin's
138
+ // dry_run_response helper):
139
+ // { ok: true, data: { dry_run: true, plan: { summary, changes[, warnings] }, ...extra } }
140
+ // Apply mode keeps each tool's pre-existing response shape unchanged.
141
+ const DRY_RUN_DESC_SUFFIX = " Pass dry_run: true to preview the change plan without mutating state.";
142
+ const DRY_RUN_FIELD = z
143
+ .boolean()
144
+ .optional()
145
+ .default(false)
146
+ .describe("When true, return the change plan { summary, changes[, warnings] } without mutating state.");
127
147
  // ── Read Tools ───────────────────────────────────────────────────────
128
148
  registerPluginTool("diviops_page_list", {
129
- description: "List pages/posts in the WordPress site. Returns title, ID, URL, status, and whether each page uses Divi builder.",
149
+ description: "List pages/posts in the WordPress site. Returns title, ID, URL, status, and whether each page uses Divi builder. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
130
150
  inputSchema: {
131
151
  post_type: z
132
152
  .string()
@@ -140,8 +160,10 @@ registerPluginTool("diviops_page_list", {
140
160
  .describe("Number of results per page (max 100)"),
141
161
  page: z.number().optional().default(1).describe("Page number"),
142
162
  },
163
+ annotations: { idempotentHint: true },
164
+ _meta: { idempotent: "true" },
143
165
  }, async ({ post_type, per_page, page }) => {
144
- const result = await wp.request("/page/list", {
166
+ const result = await wp.requestEnveloped("/page/list", {
145
167
  params: {
146
168
  post_type: post_type ?? "page",
147
169
  per_page: String(per_page ?? 20),
@@ -150,25 +172,27 @@ registerPluginTool("diviops_page_list", {
150
172
  });
151
173
  return {
152
174
  content: [
153
- { type: "text", text: JSON.stringify(result) },
175
+ { type: "text", text: serializeEnvelope(result) },
154
176
  ],
155
177
  };
156
178
  });
157
179
  registerPluginTool("diviops_page_get", {
158
- description: "Get detailed info about a specific page including its raw Divi block content.",
180
+ description: "Get detailed info about a specific page including its raw Divi block content. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing page_id returns ok:false with code 'not_found' and a hint pointing to diviops_page_list.",
159
181
  inputSchema: {
160
182
  page_id: z.number().describe("WordPress post/page ID"),
161
183
  },
184
+ annotations: { idempotentHint: true },
185
+ _meta: { idempotent: "true" },
162
186
  }, async ({ page_id }) => {
163
- const result = await wp.request(`/page/get/${page_id}`);
187
+ const result = await wp.requestEnveloped(`/page/get/${page_id}`);
164
188
  return {
165
189
  content: [
166
- { type: "text", text: JSON.stringify(result) },
190
+ { type: "text", text: serializeEnvelope(result) },
167
191
  ],
168
192
  };
169
193
  });
170
194
  registerPluginTool("diviops_page_get_layout", {
171
- description: "Get the parsed block tree for a page. Returns slim targeting metadata by default (block names, admin labels, text previews, auto_index). Use full: true for complete attrs (warning: can be very large on complex pages).",
195
+ description: "Get the parsed block tree for a page. Returns slim targeting metadata by default (block names, admin labels, text previews, auto_index). Use full: true for complete attrs (warning: can be very large on complex pages). Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing page_id returns ok:false with code 'not_found'.",
172
196
  inputSchema: {
173
197
  page_id: z.number().describe("WordPress post/page ID"),
174
198
  full: z
@@ -177,28 +201,32 @@ registerPluginTool("diviops_page_get_layout", {
177
201
  .default(false)
178
202
  .describe("Include full block attrs and raw content (default: false for slim mode)"),
179
203
  },
204
+ annotations: { idempotentHint: true },
205
+ _meta: { idempotent: "true" },
180
206
  }, async ({ page_id, full }) => {
181
- const result = await wp.request(`/page/get-layout/${page_id}`, {
207
+ const result = await wp.requestEnveloped(`/page/get-layout/${page_id}`, {
182
208
  params: full ? { full: "true" } : {},
183
209
  });
184
210
  return {
185
211
  content: [
186
- { type: "text", text: JSON.stringify(result) },
212
+ { type: "text", text: serializeEnvelope(result) },
187
213
  ],
188
214
  };
189
215
  });
190
216
  registerPluginTool("diviops_schema_list_modules", {
191
- description: "List all available Divi modules (block types) with their names, titles, and categories. Use this to discover what modules can be used in layouts.",
217
+ description: "List all available Divi modules (block types) with their names, titles, and categories. Use this to discover what modules can be used in layouts. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
218
+ annotations: { idempotentHint: true },
219
+ _meta: { idempotent: "true" },
192
220
  }, async () => {
193
- const result = await wp.request("/schema/modules");
221
+ const result = await wp.requestEnveloped("/schema/modules");
194
222
  return {
195
223
  content: [
196
- { type: "text", text: JSON.stringify(result) },
224
+ { type: "text", text: serializeEnvelope(result) },
197
225
  ],
198
226
  };
199
227
  });
200
228
  registerPluginTool("diviops_schema_get_module", {
201
- description: "Get the attribute schema for a Divi module. Default mode 'single' returns one module's schema (optimized, ~70% smaller; pass raw: true for full). Mode 'dump_all' snapshots every Divi module in one call and includes a `schema_version` hash over the canonical *PresetAttrsMap.php files — build-time entry point for the skill regen pipeline; ignores `module_name` and `raw`.",
229
+ description: "Get the attribute schema for a Divi module. Default mode 'single' returns one module's schema (optimized, ~70% smaller; pass raw: true for full). Mode 'dump_all' snapshots every Divi module in one call and includes a `schema_version` hash over the canonical *PresetAttrsMap.php files — build-time entry point for the skill regen pipeline; ignores `module_name` and `raw`. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
202
230
  inputSchema: {
203
231
  mode: z
204
232
  .enum(["single", "dump_all"])
@@ -215,66 +243,81 @@ registerPluginTool("diviops_schema_get_module", {
215
243
  .default(false)
216
244
  .describe("Return full schema including CSS selectors and VB metadata. Applies to mode='single' only."),
217
245
  },
246
+ annotations: { idempotentHint: true },
247
+ _meta: { idempotent: "true" },
218
248
  }, async ({ mode, module_name, raw }) => {
219
249
  if (mode === "dump_all") {
220
250
  // Capability gate for the dump-all surface: handled here (rather
221
251
  // than the wrapper's auto-derived `schema_get_module` key) so older
222
- // plugins without /schema/module/dump-all surface a clean upgrade
223
- // hint instead of a 404 from wp.request.
252
+ // plugins without /schema/module/dump-all return a typed envelope
253
+ // error instead of a 404 from the underlying request.
224
254
  if (handshakeState.kind === "ok" &&
225
255
  !handshakeState.capabilities["schema_get_module_dump_all"]) {
226
256
  const err = new MissingCapabilityError("schema_get_module_dump_all", handshakeState.pluginVersion);
257
+ const failure = {
258
+ ok: false,
259
+ error: {
260
+ code: ErrorCodes.CAPABILITY_MISSING,
261
+ message: err.message,
262
+ hint: "Update the diviops-agent WP plugin to a version that exposes the dump-all surface.",
263
+ },
264
+ };
227
265
  return {
228
- content: [{ type: "text", text: err.message }],
229
- isError: true,
266
+ content: [{ type: "text", text: serializeEnvelope(failure) }],
230
267
  };
231
268
  }
232
- const result = await wp.request("/schema/module/dump-all");
269
+ const result = await wp.requestEnveloped("/schema/module/dump-all");
233
270
  return {
234
- content: [{ type: "text", text: JSON.stringify(result) }],
271
+ content: [{ type: "text", text: serializeEnvelope(result) }],
235
272
  };
236
273
  }
237
274
  if (!module_name) {
275
+ const failure = {
276
+ ok: false,
277
+ error: {
278
+ code: ErrorCodes.INVALID_INPUT,
279
+ message: "module_name is required when mode='single'",
280
+ },
281
+ };
238
282
  return {
239
- content: [
240
- {
241
- type: "text",
242
- text: "module_name is required when mode='single'",
243
- },
244
- ],
245
- isError: true,
283
+ content: [{ type: "text", text: serializeEnvelope(failure) }],
246
284
  };
247
285
  }
248
- const result = await wp.request(`/schema/module/${encodeURIComponent(module_name)}`);
249
- const output = raw ? result : optimizeSchema(result);
286
+ const result = await wp.requestEnveloped(`/schema/module/${encodeURIComponent(module_name)}`);
287
+ const projected = envelopeMap(result, (data) => raw ? data : optimizeSchema(data));
250
288
  return {
251
289
  content: [
252
- { type: "text", text: JSON.stringify(output) },
290
+ { type: "text", text: serializeEnvelope(projected) },
253
291
  ],
254
292
  };
255
293
  });
256
294
  registerPluginTool("diviops_schema_get_settings", {
257
- description: "Get Divi site settings including theme options, site info, and builder version. Useful for understanding the site context before generating content.",
295
+ description: "Get Divi site settings including theme options, site info, and builder version. Useful for understanding the site context before generating content. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
296
+ annotations: { idempotentHint: true },
297
+ _meta: { idempotent: "true" },
258
298
  }, async () => {
259
- const result = await wp.request("/schema/settings");
299
+ const result = await wp.requestEnveloped("/schema/settings");
260
300
  return {
261
301
  content: [
262
- { type: "text", text: JSON.stringify(result) },
302
+ { type: "text", text: serializeEnvelope(result) },
263
303
  ],
264
304
  };
265
305
  });
266
306
  registerPluginTool("diviops_global_color_list", {
267
- description: "Get the global color palette defined in Divi. Returns all global colors that can be referenced by modules.",
307
+ description: "Get the global color palette defined in Divi. Returns all global colors that can be referenced by modules. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
308
+ annotations: { idempotentHint: true },
309
+ _meta: { idempotent: "true" },
268
310
  }, async () => {
269
- const result = await wp.request("/global-color/list");
311
+ const result = await wp.requestEnveloped("/global-color/list");
270
312
  return {
271
313
  content: [
272
- { type: "text", text: JSON.stringify(result) },
314
+ { type: "text", text: serializeEnvelope(result) },
273
315
  ],
274
316
  };
275
317
  });
276
318
  registerPluginTool("diviops_global_color_create", {
277
- description: "Add a new global color to Divi's palette. The plugin mints a fresh `gcid-<uuid>` ID (the server forwards the color entry without an id and the WP-side handler generates one) and writes to the et_global_data option in the canonical Divi shape `{color, folder, label, lastUpdated, status, usedInPosts}`. The color appears in the VB color picker after save and can be referenced via `$variable({type:color,value:{name:gcid-...}})$` tokens. Note: Divi's AI Agent bundle has a Zod schema gap that drops `label` on its own writes — our PHP path goes around that bug by writing directly to the option. CONCURRENCY: this is a read-modify-write on a single WP option with no conflict detection. If a Visual Builder session holds stale global data, its next save can clobber colors written here in the interim. Coordinate writes when VB sessions are active, or have the user reload VB after MCP color writes.",
319
+ description: "Add a new global color to Divi's palette. The plugin mints a fresh `gcid-<uuid>` ID (the server forwards the color entry without an id and the WP-side handler generates one) and writes to the et_global_data option in the canonical Divi shape `{color, folder, label, lastUpdated, status, usedInPosts}`. The color appears in the VB color picker after save and can be referenced via `$variable({type:color,value:{name:gcid-...}})$` tokens. Note: Divi's AI Agent bundle has a Zod schema gap that drops `label` on its own writes — our PHP path goes around that bug by writing directly to the option. CONCURRENCY: this is a read-modify-write on a single WP option with no conflict detection. If a Visual Builder session holds stale global data, its next save can clobber colors written here in the interim. Coordinate writes when VB sessions are active, or have the user reload VB after MCP color writes. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; input-shape rejections (non-CSS color value, missing required `color` for a new entry) return code 'invalid_input' with `error.data` documenting the failed field." +
320
+ DRY_RUN_DESC_SUFFIX,
278
321
  inputSchema: {
279
322
  color: z
280
323
  .string()
@@ -292,8 +335,11 @@ registerPluginTool("diviops_global_color_create", {
292
335
  .optional()
293
336
  .default("active")
294
337
  .describe('Color status — "active" (default, visible in picker) or "archived" (hidden but preserved).'),
338
+ dry_run: DRY_RUN_FIELD,
295
339
  },
296
- }, async ({ color, label, folder, status }) => {
340
+ annotations: { idempotentHint: false },
341
+ _meta: { idempotent: "false" },
342
+ }, async ({ color, label, folder, status, dry_run }) => {
297
343
  const colorEntry = { color };
298
344
  if (label !== undefined)
299
345
  colorEntry.label = label;
@@ -301,14 +347,18 @@ registerPluginTool("diviops_global_color_create", {
301
347
  colorEntry.folder = folder;
302
348
  if (status)
303
349
  colorEntry.status = status;
304
- const result = await wp.request("/global-color/upsert", {
350
+ const body = { colors: [colorEntry], mode: "merge" };
351
+ if (dry_run)
352
+ body.dry_run = true;
353
+ const result = await wp.requestEnveloped("/global-color/upsert", {
305
354
  method: "POST",
306
- body: { colors: [colorEntry], mode: "merge" },
355
+ body,
307
356
  });
308
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
357
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
309
358
  });
310
359
  registerPluginTool("diviops_global_color_update", {
311
- description: "Update an existing global color by gcid. Only provided fields are updated; omitted fields are preserved. The lastUpdated timestamp is bumped on every write. Use diviops_global_color_list first to find the gcid for a color. CONCURRENCY: same VB-session race caveat as diviops_global_color_create — the write is read-modify-write on a single WP option, so an active VB session's next save can clobber this update.",
360
+ description: "Update an existing global color by gcid. Only provided fields are updated; omitted fields are preserved. The lastUpdated timestamp is bumped on every write. Use diviops_global_color_list first to find the gcid for a color. NOTE: the underlying upsert is merge-mode — supplying a gcid that doesn't yet exist creates a new color with that gcid (provided it satisfies the gcid charset/length rules) rather than failing as 'not found'. Pre-check via diviops_global_color_list if you need strict-update semantics. CONCURRENCY: same VB-session race caveat as diviops_global_color_create — the write is read-modify-write on a single WP option, so an active VB session's next save can clobber this update. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; malformed gcid charset/length returns code 'invalid_input' with `error.data` documenting the failed field; non-CSS color value returns code 'invalid_input'; attempts to write to a customizer-bound default (gcid-primary-color / gcid-secondary-color / gcid-heading-color / gcid-body-color / gcid-link-color) return code 'variable.customizer_default_immutable' (HTTP 403) with `error.data = { id, managed_by: 'wp_customizer' }` — same code as diviops_variable_delete because the identity is identical (5.4+ unified gcid-* into the variable manager while preserving customizer-binding for the five legacy defaults)." +
361
+ DRY_RUN_DESC_SUFFIX,
312
362
  inputSchema: {
313
363
  gcid: z
314
364
  .string()
@@ -329,8 +379,11 @@ registerPluginTool("diviops_global_color_update", {
329
379
  .enum(["active", "archived"])
330
380
  .optional()
331
381
  .describe('Change status — "active" or "archived". Omit to keep existing.'),
382
+ dry_run: DRY_RUN_FIELD,
332
383
  },
333
- }, async ({ gcid, color, label, folder, status }) => {
384
+ annotations: { idempotentHint: false },
385
+ _meta: { idempotent: "conditional" },
386
+ }, async ({ gcid, color, label, folder, status, dry_run }) => {
334
387
  const colorEntry = { id: gcid };
335
388
  if (color !== undefined)
336
389
  colorEntry.color = color;
@@ -340,14 +393,18 @@ registerPluginTool("diviops_global_color_update", {
340
393
  colorEntry.folder = folder;
341
394
  if (status)
342
395
  colorEntry.status = status;
343
- const result = await wp.request("/global-color/upsert", {
396
+ const body = { colors: [colorEntry], mode: "merge" };
397
+ if (dry_run)
398
+ body.dry_run = true;
399
+ const result = await wp.requestEnveloped("/global-color/upsert", {
344
400
  method: "POST",
345
- body: { colors: [colorEntry], mode: "merge" },
401
+ body,
346
402
  });
347
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
403
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
348
404
  });
349
405
  registerPluginTool("diviops_global_color_delete", {
350
- description: "Delete a global color from the registry by gcid. Refuses by default if the color is tracked as referenced by any post (per Divi's `usedInPosts` index — pass `force: true` to delete anyway; orphan refs will render as invalid CSS until pages are re-saved through VB). Always refuses to delete the 5 customizer-bound defaults (gcid-primary-color, gcid-secondary-color, gcid-heading-color, gcid-body-color, gcid-link-color) regardless of force — those must be edited via WP Customizer. CONCURRENCY: same VB-session race caveat as diviops_global_color_create — an active VB session's next save can re-introduce a color we just deleted if the session held stale data.",
406
+ description: "Delete a global color from the registry by gcid. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }. Live-reference soft-block: returns code 'conflict' (HTTP 409) when Divi's `usedInPosts` index lists references `error.data = { id, ref_count, used_in_posts }` carries Divi's pass-through reference list (Divi-maintained on VB save; element shape is whatever Divi emits, NOT the discriminated-union `locations[]` shape diviops_variable_delete builds via parse_blocks). Pass `force: true` to override; orphan refs will render as invalid CSS until pages are re-saved through VB. Always refuses to delete the 5 customizer-bound defaults (gcid-primary-color, gcid-secondary-color, gcid-heading-color, gcid-body-color, gcid-link-color) regardless of force — returns code 'variable.customizer_default_immutable' (HTTP 403) with `error.data = { id, managed_by: 'wp_customizer' }`. Missing gcids return 'not_found' (HTTP 404). Malformed gcid (empty or missing `gcid-` prefix) returns 'invalid_input'. CONCURRENCY: same VB-session race caveat as diviops_global_color_create — an active VB session's next save can re-introduce a color we just deleted if the session held stale data." +
407
+ DRY_RUN_DESC_SUFFIX,
351
408
  inputSchema: {
352
409
  gcid: z
353
410
  .string()
@@ -357,29 +414,36 @@ registerPluginTool("diviops_global_color_delete", {
357
414
  .optional()
358
415
  .default(false)
359
416
  .describe("If true, delete even when usedInPosts shows live references. Customizer-bound defaults remain protected regardless."),
417
+ dry_run: DRY_RUN_FIELD,
360
418
  },
361
- }, async ({ gcid, force }) => {
419
+ annotations: { idempotentHint: true },
420
+ _meta: { idempotent: "true" },
421
+ }, async ({ gcid, force, dry_run }) => {
362
422
  const body = { gcid };
363
423
  if (force)
364
424
  body.force = true;
365
- const result = await wp.request("/global-color/delete", {
425
+ if (dry_run)
426
+ body.dry_run = true;
427
+ const result = await wp.requestEnveloped("/global-color/delete", {
366
428
  method: "POST",
367
429
  body,
368
430
  });
369
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
431
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
370
432
  });
371
433
  registerPluginTool("diviops_global_font_list", {
372
- description: "Get the global font definitions from Divi settings.",
434
+ description: "Get the global font definitions from Divi settings. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
435
+ annotations: { idempotentHint: true },
436
+ _meta: { idempotent: "true" },
373
437
  }, async () => {
374
- const result = await wp.request("/global-font/list");
438
+ const result = await wp.requestEnveloped("/global-font/list");
375
439
  return {
376
440
  content: [
377
- { type: "text", text: JSON.stringify(result) },
441
+ { type: "text", text: serializeEnvelope(result) },
378
442
  ],
379
443
  };
380
444
  });
381
445
  registerPluginTool("diviops_meta_find_icon", {
382
- description: "Search for icons by keyword. Returns matching icons with unicode, type (fa/divi), and weight. Use the returned unicode/type/weight in Blurb icon or Icon module attributes.",
446
+ description: "Search for icons by keyword. Returns matching icons with unicode, type (fa/divi), and weight. Use the returned unicode/type/weight in Blurb icon or Icon module attributes. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
383
447
  inputSchema: {
384
448
  query: z
385
449
  .string()
@@ -395,71 +459,85 @@ registerPluginTool("diviops_meta_find_icon", {
395
459
  .default(10)
396
460
  .describe("Max results (default 10, max 50)"),
397
461
  },
462
+ annotations: { idempotentHint: true },
463
+ _meta: { idempotent: "true" },
398
464
  }, async ({ query, type, limit }) => {
399
- const result = await wp.request(`/meta/find-icon?q=${encodeURIComponent(query)}&type=${type ?? "all"}&limit=${limit ?? 10}`);
465
+ const result = await wp.requestEnveloped(`/meta/find-icon?q=${encodeURIComponent(query)}&type=${type ?? "all"}&limit=${limit ?? 10}`);
400
466
  return {
401
467
  content: [
402
- { type: "text", text: JSON.stringify(result) },
468
+ { type: "text", text: serializeEnvelope(result) },
403
469
  ],
404
470
  };
405
471
  });
406
472
  // ── Write Tools ──────────────────────────────────────────────────────
407
473
  registerPluginTool("diviops_page_update_content", {
408
- description: "Update the content of a page with Divi block markup. The content should be valid WordPress block markup using divi/* blocks. IMPORTANT: This overwrites the entire page content.",
474
+ description: "Update the content of a page with Divi block markup. The content should be valid WordPress block markup using divi/* blocks. IMPORTANT: This overwrites the entire page content. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing page_id returns 'not_found', edit-permission failures return 'forbidden' (HTTP 403), non-string content returns 'invalid_input' with `error.data = { field, received_type }`." +
475
+ DRY_RUN_DESC_SUFFIX,
409
476
  inputSchema: {
410
477
  page_id: z.number().describe("WordPress post/page ID to update"),
411
478
  content: z
412
479
  .string()
413
480
  .describe("Full page content in WordPress block markup format (<!-- wp:divi/section -->...<!-- /wp:divi/section -->)"),
481
+ dry_run: DRY_RUN_FIELD,
414
482
  },
415
- }, async ({ page_id, content }) => {
483
+ annotations: { idempotentHint: false },
484
+ _meta: { idempotent: "conditional" },
485
+ }, async ({ page_id, content, dry_run }) => {
416
486
  const hits = findForeignVarRefs(content, "content");
417
487
  if (hits.length > 0)
418
488
  return isolationErrorResult("diviops_page_update_content", hits);
419
- const result = await wp.request(`/page/update-content/${page_id}`, {
489
+ const body = { content };
490
+ if (dry_run)
491
+ body.dry_run = true;
492
+ const result = await wp.requestEnveloped(`/page/update-content/${page_id}`, {
420
493
  method: "POST",
421
- body: { content },
494
+ body,
422
495
  });
423
496
  return {
424
497
  content: [
425
- { type: "text", text: JSON.stringify(result) },
498
+ { type: "text", text: serializeEnvelope(result) },
426
499
  ],
427
500
  };
428
501
  });
429
502
  registerPluginTool("diviops_render_preview", {
430
- description: "Render Divi block markup to HTML. Use this to preview what the output will look like before saving. Useful for validation.",
503
+ description: "Render Divi block markup to HTML. Use this to preview what the output will look like before saving. Useful for validation. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { rendered_html: string }. Errors map to `invalid_input` (non-string content) or `divi_error` (parser/render exception, with truncated message and full detail in `error.data.detail`).",
431
504
  inputSchema: {
432
505
  content: z.string().describe("Divi block markup to render to HTML"),
433
506
  },
507
+ annotations: { idempotentHint: true },
508
+ _meta: { idempotent: "true" },
434
509
  }, async ({ content }) => {
435
- const result = await wp.request("/render", {
510
+ const result = await wp.requestEnveloped("/render", {
436
511
  method: "POST",
437
512
  body: { content },
438
513
  });
439
514
  return {
440
515
  content: [
441
- { type: "text", text: JSON.stringify(result) },
516
+ { type: "text", text: serializeEnvelope(result) },
442
517
  ],
443
518
  };
444
519
  });
445
520
  registerPluginTool("diviops_validate_blocks", {
446
- description: "Validate Divi block markup before saving. Checks structure (malformed comments, unknown blocks, missing builderVersion), required attributes (layout display on containers), and known pitfalls (button padding path, icon.enable, gradient enabled/positions). Returns errors and warnings.",
521
+ description: "Validate Divi block markup before saving. Checks structure (malformed comments, unknown blocks, missing builderVersion), required attributes (layout display on containers), and known pitfalls (button padding path, icon.enable, gradient enabled/positions). Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { valid: bool, total_blocks: number, errors: Finding[], warnings: Finding[] } where each Finding is { block, index, code, message, path? }. Note: shape errors detected in the markup surface as success-branch `data.errors[]` entries (NOT `validation_failed` envelopes) — the findings array is the payload, not an error. The envelope's error branch fires only for tool-level failures (`invalid_input` for non-string content; `divi_error` for an exception in the walker).",
447
522
  inputSchema: {
448
523
  content: z.string().describe("Divi block markup to validate"),
449
524
  },
525
+ annotations: { idempotentHint: true },
526
+ _meta: { idempotent: "true" },
450
527
  }, async ({ content }) => {
451
- const result = await wp.request("/validate/blocks", {
528
+ const result = await wp.requestEnveloped("/validate/blocks", {
452
529
  method: "POST",
453
530
  body: { content },
454
531
  });
455
532
  return {
456
533
  content: [
457
- { type: "text", text: JSON.stringify(result) },
534
+ { type: "text", text: serializeEnvelope(result) },
458
535
  ],
459
536
  };
460
537
  });
461
538
  registerPluginTool("diviops_section_append", {
462
- description: "Append a Divi section to an existing page without overwriting other content. Use this to incrementally build pages.",
539
+ description: "Append a Divi section to an existing page without overwriting other content. Use this to incrementally build pages. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing page_id returns 'not_found' with `error.data.target_kind = \"page\"`, edit-permission failures return 'forbidden' (HTTP 403), non-string content or invalid position returns 'invalid_input' with `error.data = { field, ... }`." +
540
+ DRY_RUN_DESC_SUFFIX,
463
541
  inputSchema: {
464
542
  page_id: z.number().describe("WordPress post/page ID"),
465
543
  content: z
@@ -470,23 +548,30 @@ registerPluginTool("diviops_section_append", {
470
548
  .optional()
471
549
  .default("end")
472
550
  .describe('Where to insert: "start" or "end" (default)'),
551
+ dry_run: DRY_RUN_FIELD,
473
552
  },
474
- }, async ({ page_id, content, position }) => {
553
+ annotations: { idempotentHint: false },
554
+ _meta: { idempotent: "false" },
555
+ }, async ({ page_id, content, position, dry_run }) => {
475
556
  const hits = findForeignVarRefs(content, "content");
476
557
  if (hits.length > 0)
477
558
  return isolationErrorResult("diviops_section_append", hits);
478
- const result = await wp.request(`/section/append/${page_id}`, {
559
+ const body = { content, position: position ?? "end" };
560
+ if (dry_run)
561
+ body.dry_run = true;
562
+ const result = await wp.requestEnveloped(`/section/append/${page_id}`, {
479
563
  method: "POST",
480
- body: { content, position: position ?? "end" },
564
+ body,
481
565
  });
482
566
  return {
483
567
  content: [
484
- { type: "text", text: JSON.stringify(result) },
568
+ { type: "text", text: serializeEnvelope(result) },
485
569
  ],
486
570
  };
487
571
  });
488
572
  registerPluginTool("diviops_section_replace", {
489
- description: "Replace a section on a page. Target by admin label OR text content. Use occurrence when multiple sections match.",
573
+ description: "Replace a section on a page. Target by admin label OR text content. Use occurrence when multiple sections match. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing section returns 'not_found' with `error.data = { target_kind: \"section\", ... }`, missing/ambiguous selectors return 'invalid_input' with `error.data.reason`." +
574
+ DRY_RUN_DESC_SUFFIX,
490
575
  inputSchema: {
491
576
  page_id: z.number().describe("WordPress post/page ID"),
492
577
  label: z
@@ -507,8 +592,11 @@ registerPluginTool("diviops_section_replace", {
507
592
  .optional()
508
593
  .default(1)
509
594
  .describe("Which match to target (1-based, default: 1)"),
595
+ dry_run: DRY_RUN_FIELD,
510
596
  },
511
- }, async ({ page_id, label, match_text, content, occurrence }) => {
597
+ annotations: { idempotentHint: false },
598
+ _meta: { idempotent: "conditional" },
599
+ }, async ({ page_id, label, match_text, content, occurrence, dry_run }) => {
512
600
  const hits = findForeignVarRefs(content, "content");
513
601
  if (hits.length > 0)
514
602
  return isolationErrorResult("diviops_section_replace", hits);
@@ -517,18 +605,21 @@ registerPluginTool("diviops_section_replace", {
517
605
  body.label = label;
518
606
  if (match_text)
519
607
  body.match_text = match_text;
520
- const result = await wp.request(`/section/replace/${page_id}`, {
608
+ if (dry_run)
609
+ body.dry_run = true;
610
+ const result = await wp.requestEnveloped(`/section/replace/${page_id}`, {
521
611
  method: "POST",
522
612
  body,
523
613
  });
524
614
  return {
525
615
  content: [
526
- { type: "text", text: JSON.stringify(result) },
616
+ { type: "text", text: serializeEnvelope(result) },
527
617
  ],
528
618
  };
529
619
  });
530
620
  registerPluginTool("diviops_section_remove", {
531
- description: "Remove a section from a page. Target by admin label OR text content. Use occurrence when multiple sections match.",
621
+ description: "Remove a section from a page. Target by admin label OR text content. Use occurrence when multiple sections match. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; section selectors lack identity-preserving repeat-call detection so a removal of an already-removed section returns 'not_found' (HTTP 404) — the side-effect (section is gone) holds regardless of how many times you call." +
622
+ DRY_RUN_DESC_SUFFIX,
532
623
  inputSchema: {
533
624
  page_id: z.number().describe("WordPress post/page ID"),
534
625
  label: z
@@ -546,25 +637,30 @@ registerPluginTool("diviops_section_remove", {
546
637
  .optional()
547
638
  .default(1)
548
639
  .describe("Which match to target (1-based, default: 1)"),
640
+ dry_run: DRY_RUN_FIELD,
549
641
  },
550
- }, async ({ page_id, label, match_text, occurrence }) => {
642
+ annotations: { idempotentHint: true },
643
+ _meta: { idempotent: "true" },
644
+ }, async ({ page_id, label, match_text, occurrence, dry_run }) => {
551
645
  const body = { occurrence };
552
646
  if (label)
553
647
  body.label = label;
554
648
  if (match_text)
555
649
  body.match_text = match_text;
556
- const result = await wp.request(`/section/remove/${page_id}`, {
650
+ if (dry_run)
651
+ body.dry_run = true;
652
+ const result = await wp.requestEnveloped(`/section/remove/${page_id}`, {
557
653
  method: "POST",
558
654
  body,
559
655
  });
560
656
  return {
561
657
  content: [
562
- { type: "text", text: JSON.stringify(result) },
658
+ { type: "text", text: serializeEnvelope(result) },
563
659
  ],
564
660
  };
565
661
  });
566
662
  registerPluginTool("diviops_section_get", {
567
- description: "Get the raw block markup of a section. Target by admin label OR text content. Use occurrence when multiple sections match. Returns total_matches warning when duplicates exist.",
663
+ description: "Get the raw block markup of a section. Target by admin label OR text content. Use occurrence when multiple sections match. Returns total_matches warning when duplicates exist. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing section returns 'not_found' with `error.data.target_kind = \"section\"`.",
568
664
  inputSchema: {
569
665
  page_id: z.number().describe("WordPress post/page ID"),
570
666
  label: z
@@ -583,6 +679,8 @@ registerPluginTool("diviops_section_get", {
583
679
  .default(1)
584
680
  .describe("Which match to target (1-based, default: 1)"),
585
681
  },
682
+ annotations: { idempotentHint: true },
683
+ _meta: { idempotent: "true" },
586
684
  }, async ({ page_id, label, match_text, occurrence }) => {
587
685
  const params = { occurrence: String(occurrence) };
588
686
  if (label)
@@ -590,15 +688,16 @@ registerPluginTool("diviops_section_get", {
590
688
  if (match_text)
591
689
  params.match_text = match_text;
592
690
  const qs = new URLSearchParams(params).toString();
593
- const result = await wp.request(`/section/get/${page_id}?${qs}`);
691
+ const result = await wp.requestEnveloped(`/section/get/${page_id}?${qs}`);
594
692
  return {
595
693
  content: [
596
- { type: "text", text: JSON.stringify(result) },
694
+ { type: "text", text: serializeEnvelope(result) },
597
695
  ],
598
696
  };
599
697
  });
600
698
  registerPluginTool("diviops_module_update", {
601
- description: 'Update specific attributes of a module. Target by auto_index (e.g. "text:5"), admin label, or text content. Uses dot notation for attribute paths. Example: {"content.decoration.headingFont.h2.font.desktop.value.color": "#ff0000"}. For paths whose key segments contain literal dots — notably Composable Settings preset slots like groupPreset["title.decoration.spacing"] — escape the inner dots with `\\.` to keep the segment intact: {"groupPreset.title\\\\.decoration\\\\.spacing.presetId": ["uuid"]}. Priority: auto_index > label > match_text. Use occurrence with label when duplicates exist.',
699
+ description: 'Update specific attributes of a module. Target by auto_index (e.g. "text:5"), admin label, or text content. Uses dot notation for attribute paths. Example: {"content.decoration.headingFont.h2.font.desktop.value.color": "#ff0000"}. For paths whose key segments contain literal dots — notably Composable Settings preset slots like groupPreset["title.decoration.spacing"] — escape the inner dots with `\\.` to keep the segment intact: {"groupPreset.title\\\\.decoration\\\\.spacing.presetId": ["uuid"]}. Priority: auto_index > label > match_text. Use occurrence with label when duplicates exist. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing module returns code "not_found" with error.data = { target_kind: "module", target_mode, target_value, page_id }, non-array attrs returns code "invalid_input" with error.data.field = "attrs", malformed Divi block markup surfaces code "divi_error" (HTTP 500).' +
700
+ DRY_RUN_DESC_SUFFIX,
602
701
  inputSchema: {
603
702
  page_id: z.number().describe("WordPress post/page ID"),
604
703
  label: z
@@ -623,8 +722,11 @@ registerPluginTool("diviops_module_update", {
623
722
  attrs: z
624
723
  .record(z.string(), z.any())
625
724
  .describe("Attribute paths (dot notation) and their new values"),
725
+ dry_run: DRY_RUN_FIELD,
626
726
  },
627
- }, async ({ page_id, label, match_text, auto_index, occurrence, attrs }) => {
727
+ annotations: { idempotentHint: false },
728
+ _meta: { idempotent: "conditional" },
729
+ }, async ({ page_id, label, match_text, auto_index, occurrence, attrs, dry_run }) => {
628
730
  const hits = scanAttrsForForeignVarRefs(attrs);
629
731
  if (hits.length > 0)
630
732
  return isolationErrorResult("diviops_module_update", hits);
@@ -637,18 +739,21 @@ registerPluginTool("diviops_module_update", {
637
739
  body.match_text = match_text;
638
740
  if (occurrence > 1)
639
741
  body.occurrence = occurrence;
640
- const result = await wp.request(`/module/update/${page_id}`, {
742
+ if (dry_run)
743
+ body.dry_run = true;
744
+ const result = await wp.requestEnveloped(`/module/update/${page_id}`, {
641
745
  method: "POST",
642
746
  body,
643
747
  });
644
748
  return {
645
749
  content: [
646
- { type: "text", text: JSON.stringify(result) },
750
+ { type: "text", text: serializeEnvelope(result) },
647
751
  ],
648
752
  };
649
753
  });
650
754
  registerPluginTool("diviops_module_move", {
651
- description: 'Move a module to a new position on the page. Specify source and target blocks using auto_index (e.g. "text:3"), admin label, or text content. Position "before" or "after" the target. Works with any block type including sections, rows, and modules. Both blocks are found in the original content, so auto_index values refer to positions before the move.',
755
+ description: 'Move a module to a new position on the page. Specify source and target blocks using auto_index (e.g. "text:3"), admin label, or text content. Position "before" or "after" the target. Works with any block type including sections, rows, and modules. Both blocks are found in the original content, so auto_index values refer to positions before the move. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing source/target blocks return code "not_found" with error.data = { target_kind: "block", context: "source"|"target", ... }, moving a block into itself returns code "module.overlap" (HTTP 400).' +
756
+ DRY_RUN_DESC_SUFFIX,
652
757
  inputSchema: {
653
758
  page_id: z.number().describe("WordPress post/page ID"),
654
759
  source_label: z
@@ -692,8 +797,11 @@ registerPluginTool("diviops_module_move", {
692
797
  position: z
693
798
  .enum(["before", "after"])
694
799
  .describe("Place the source before or after the target"),
800
+ dry_run: DRY_RUN_FIELD,
695
801
  },
696
- }, async ({ page_id, source_label, source_match_text, source_auto_index, source_occurrence, target_label, target_match_text, target_auto_index, target_occurrence, position, }) => {
802
+ annotations: { idempotentHint: false },
803
+ _meta: { idempotent: "conditional" },
804
+ }, async ({ page_id, source_label, source_match_text, source_auto_index, source_occurrence, target_label, target_match_text, target_auto_index, target_occurrence, position, dry_run, }) => {
697
805
  const body = { position };
698
806
  if (source_label)
699
807
  body.source_label = source_label;
@@ -711,26 +819,32 @@ registerPluginTool("diviops_module_move", {
711
819
  body.target_auto_index = target_auto_index;
712
820
  if (target_occurrence > 1)
713
821
  body.target_occurrence = target_occurrence;
714
- const result = await wp.request(`/module/move/${page_id}`, {
822
+ if (dry_run)
823
+ body.dry_run = true;
824
+ const result = await wp.requestEnveloped(`/module/move/${page_id}`, {
715
825
  method: "POST",
716
826
  body,
717
827
  });
718
828
  return {
719
829
  content: [
720
- { type: "text", text: JSON.stringify(result) },
830
+ { type: "text", text: serializeEnvelope(result) },
721
831
  ],
722
832
  };
723
833
  });
724
834
  registerPluginTool("diviops_module_lock", {
725
- description: 'Lock a module so VB users cannot edit it. Sets attrs.locked = {desktop: {value: "on"}} per Divi\'s per-breakpoint convention (verified via VB-save probe). Locked modules render normally on frontend; only VB-side editing is gated. Same targeting pattern as diviops_module_update — pick one of label / match_text / auto_index. Use diviops_module_unlock to reverse.',
835
+ description: 'Lock a module so VB users cannot edit it. Sets attrs.locked = {desktop: {value: "on"}} per Divi\'s per-breakpoint convention (verified via VB-save probe). Locked modules render normally on frontend; only VB-side editing is gated. Same targeting pattern as diviops_module_update — pick one of label / match_text / auto_index. Use diviops_module_unlock to reverse. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing module returns code "not_found" with error.data.target_kind = "module".' +
836
+ DRY_RUN_DESC_SUFFIX,
726
837
  inputSchema: {
727
838
  page_id: z.number().describe("WordPress post/page ID"),
728
839
  label: z.string().optional().describe("Admin label of the module to lock (exact match)"),
729
840
  match_text: z.string().optional().describe("Text to search for in module markup (case-insensitive)"),
730
841
  auto_index: z.string().optional().describe('Auto-index in "type:N" format (e.g. "text:3")'),
731
842
  occurrence: z.number().int().min(1).optional().default(1).describe("Which occurrence when multiple modules share the same label (1-based)"),
843
+ dry_run: DRY_RUN_FIELD,
732
844
  },
733
- }, async ({ page_id, label, match_text, auto_index, occurrence }) => {
845
+ annotations: { idempotentHint: false },
846
+ _meta: { idempotent: "false" },
847
+ }, async ({ page_id, label, match_text, auto_index, occurrence, dry_run }) => {
734
848
  const body = {};
735
849
  if (label)
736
850
  body.label = label;
@@ -740,19 +854,25 @@ registerPluginTool("diviops_module_lock", {
740
854
  body.auto_index = auto_index;
741
855
  if (occurrence && occurrence > 1)
742
856
  body.occurrence = occurrence;
743
- const result = await wp.request(`/module/lock/${page_id}`, { method: "POST", body });
744
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
857
+ if (dry_run)
858
+ body.dry_run = true;
859
+ const result = await wp.requestEnveloped(`/module/lock/${page_id}`, { method: "POST", body });
860
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
745
861
  });
746
862
  registerPluginTool("diviops_module_unlock", {
747
- description: "Unlock a module by removing attrs.locked entirely. Matches Divi VB's convention: unlocked = attribute absent (NOT {value: 'off'}) — VB doesn't write a falsy value on unlock, it removes the field. Same targeting pattern as diviops_module_lock.",
863
+ description: "Unlock a module by removing attrs.locked entirely. Matches Divi VB's convention: unlocked = attribute absent (NOT {value: 'off'}) — VB doesn't write a falsy value on unlock, it removes the field. Same targeting pattern as diviops_module_lock. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing module returns 'not_found' with `error.data.target_kind = \"module\"`." +
864
+ DRY_RUN_DESC_SUFFIX,
748
865
  inputSchema: {
749
866
  page_id: z.number().describe("WordPress post/page ID"),
750
867
  label: z.string().optional().describe("Admin label of the module to unlock (exact match)"),
751
868
  match_text: z.string().optional().describe("Text to search for in module markup (case-insensitive)"),
752
869
  auto_index: z.string().optional().describe('Auto-index in "type:N" format'),
753
870
  occurrence: z.number().int().min(1).optional().default(1).describe("Which occurrence when multiple modules share the same label (1-based)"),
871
+ dry_run: DRY_RUN_FIELD,
754
872
  },
755
- }, async ({ page_id, label, match_text, auto_index, occurrence }) => {
873
+ annotations: { idempotentHint: false },
874
+ _meta: { idempotent: "false" },
875
+ }, async ({ page_id, label, match_text, auto_index, occurrence, dry_run }) => {
756
876
  const body = {};
757
877
  if (label)
758
878
  body.label = label;
@@ -762,11 +882,14 @@ registerPluginTool("diviops_module_unlock", {
762
882
  body.auto_index = auto_index;
763
883
  if (occurrence && occurrence > 1)
764
884
  body.occurrence = occurrence;
765
- const result = await wp.request(`/module/unlock/${page_id}`, { method: "POST", body });
766
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
885
+ if (dry_run)
886
+ body.dry_run = true;
887
+ const result = await wp.requestEnveloped(`/module/unlock/${page_id}`, { method: "POST", body });
888
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
767
889
  });
768
890
  registerPluginTool("diviops_module_clone", {
769
- description: 'Clone a module by deep-copying its block JSON and inserting it next to the source within the same parent container. Position controls before/after placement (default "after"). Module IDs are reassigned by Divi at render time from the block tree position, so the clone gets fresh IDs automatically. Same targeting pattern as diviops_module_lock.',
891
+ description: 'Clone a module by deep-copying its block JSON and inserting it next to the source within the same parent container. Position controls before/after placement (default "after"). Module IDs are reassigned by Divi at render time from the block tree position, so the clone gets fresh IDs automatically. Same targeting pattern as diviops_module_lock. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing module returns code "not_found" with error.data.target_kind = "module", malformed parent containers surface code "divi_error" (HTTP 500).' +
892
+ DRY_RUN_DESC_SUFFIX,
770
893
  inputSchema: {
771
894
  page_id: z.number().describe("WordPress post/page ID"),
772
895
  label: z.string().optional().describe("Admin label of the module to clone (exact match)"),
@@ -774,8 +897,11 @@ registerPluginTool("diviops_module_clone", {
774
897
  auto_index: z.string().optional().describe('Auto-index in "type:N" format'),
775
898
  occurrence: z.number().int().min(1).optional().default(1).describe("Which occurrence when multiple modules share the same label (1-based)"),
776
899
  position: z.enum(["before", "after"]).optional().default("after").describe('Place the clone "before" or "after" the source module within its parent.'),
900
+ dry_run: DRY_RUN_FIELD,
777
901
  },
778
- }, async ({ page_id, label, match_text, auto_index, occurrence, position }) => {
902
+ annotations: { idempotentHint: false },
903
+ _meta: { idempotent: "false" },
904
+ }, async ({ page_id, label, match_text, auto_index, occurrence, position, dry_run }) => {
779
905
  const body = {};
780
906
  if (label)
781
907
  body.label = label;
@@ -787,11 +913,14 @@ registerPluginTool("diviops_module_clone", {
787
913
  body.occurrence = occurrence;
788
914
  if (position)
789
915
  body.position = position;
790
- const result = await wp.request(`/module/clone/${page_id}`, { method: "POST", body });
791
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
916
+ if (dry_run)
917
+ body.dry_run = true;
918
+ const result = await wp.requestEnveloped(`/module/clone/${page_id}`, { method: "POST", body });
919
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
792
920
  });
793
921
  registerPluginTool("diviops_page_create", {
794
- description: "Create a new WordPress page, optionally with Divi block content.",
922
+ description: "Create a new WordPress page, optionally with Divi block content. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; non-string content or invalid status return code 'invalid_input' with `error.data` documenting the failed field." +
923
+ DRY_RUN_DESC_SUFFIX,
795
924
  inputSchema: {
796
925
  title: z.string().describe("Page title"),
797
926
  content: z
@@ -804,25 +933,31 @@ registerPluginTool("diviops_page_create", {
804
933
  .optional()
805
934
  .default("draft")
806
935
  .describe("Post status"),
936
+ dry_run: DRY_RUN_FIELD,
807
937
  },
808
- }, async ({ title, content, status }) => {
938
+ annotations: { idempotentHint: false },
939
+ _meta: { idempotent: "false" },
940
+ }, async ({ title, content, status, dry_run }) => {
809
941
  if (content) {
810
942
  const hits = findForeignVarRefs(content, "content");
811
943
  if (hits.length > 0)
812
944
  return isolationErrorResult("diviops_page_create", hits);
813
945
  }
814
- const result = await wp.request("/page/create", {
946
+ const body = { title, content: content ?? "", status: status ?? "draft" };
947
+ if (dry_run)
948
+ body.dry_run = true;
949
+ const result = await wp.requestEnveloped("/page/create", {
815
950
  method: "POST",
816
- body: { title, content: content ?? "", status: status ?? "draft" },
951
+ body,
817
952
  });
818
953
  return {
819
954
  content: [
820
- { type: "text", text: JSON.stringify(result) },
955
+ { type: "text", text: serializeEnvelope(result) },
821
956
  ],
822
957
  };
823
958
  });
824
959
  registerPluginTool("diviops_page_trash", {
825
- description: "Trash or permanently delete a page/post. Defaults to trash (reversible via WP Admin → Trash). Pass force=true to permanently delete (wp_delete_post — irreversible). Idempotent: trashing an already-trashed post is a no-op. Pass dry_run=true to preview without mutating. Replaces wp-cli `post delete --force=0|1` routing for AI-agent callers (typed input, deterministic envelope).",
960
+ description: "Trash or permanently delete a page/post. Defaults to trash (reversible via WP Admin → Trash). Pass force=true to permanently delete (wp_delete_post — irreversible). Idempotent: trashing an already-trashed post returns ok:true with `data.already_trashed = true` (repeat-safe semantics for AI-agent retries). Pass dry_run=true to preview without mutating. Replaces wp-cli `post delete --force=0|1` routing for AI-agent callers (typed input, deterministic envelope). Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing post_id returns 'not_found', delete-permission failures return 'forbidden' (HTTP 403). Note: dry_run currently returns the route-specific shape rather than the standardized `data.plan = { summary, changes[] }` shape used by tools introduced after the dry_run convention was generalized; plan-shape standardization is tracked separately for the pre-existing dry_run wave.",
826
961
  inputSchema: {
827
962
  post_id: z.number().int().describe("WordPress post/page ID"),
828
963
  force: z
@@ -836,8 +971,10 @@ registerPluginTool("diviops_page_trash", {
836
971
  .default(false)
837
972
  .describe("When true, return the change plan without mutating state."),
838
973
  },
974
+ annotations: { idempotentHint: true },
975
+ _meta: { idempotent: "true" },
839
976
  }, async ({ post_id, force, dry_run }) => {
840
- const result = await wp.request(`/page/trash/${post_id}`, {
977
+ const result = await wp.requestEnveloped(`/page/trash/${post_id}`, {
841
978
  method: "POST",
842
979
  body: {
843
980
  force: force ?? false,
@@ -846,12 +983,12 @@ registerPluginTool("diviops_page_trash", {
846
983
  });
847
984
  return {
848
985
  content: [
849
- { type: "text", text: JSON.stringify(result) },
986
+ { type: "text", text: serializeEnvelope(result) },
850
987
  ],
851
988
  };
852
989
  });
853
990
  registerPluginTool("diviops_page_update_status", {
854
- description: "Update a page's post_status. Valid statuses: publish, draft, private, pending, future. status='future' requires date_gmt (ISO 8601 UTC, must be in the future) — server writes both post_date_gmt and the site-tz post_date so WP's scheduler picks it up. status='publish' on a previously-scheduled post clears the future date so it publishes immediately. Idempotent: same-status update is a no-op. Pass dry_run=true to preview. Replaces wp-cli `post update --post_status=...` routing.",
991
+ description: "Update a page's post_status. Valid statuses: publish, draft, private, pending, future. status='future' requires date_gmt (ISO 8601 UTC, must be in the future) — server writes both post_date_gmt and the site-tz post_date so WP's scheduler picks it up. status='publish' on a previously-scheduled post clears the future date so it publishes immediately. Idempotent: same-status update returns ok:true with `data.noop = true`. Pass dry_run=true to preview. Replaces wp-cli `post update --post_status=...` routing. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing post_id returns 'not_found', edit-permission failures return 'forbidden' (HTTP 403); status enum violations and date_gmt validation failures return 'invalid_input' with `error.data` documenting the field. Note: dry_run currently returns the route-specific shape rather than the standardized `data.plan = { summary, changes[] }` shape used by tools introduced after the dry_run convention was generalized; plan-shape standardization is tracked separately for the pre-existing dry_run wave.",
855
992
  inputSchema: {
856
993
  post_id: z.number().int().describe("WordPress post/page ID"),
857
994
  status: z
@@ -867,6 +1004,8 @@ registerPluginTool("diviops_page_update_status", {
867
1004
  .default(false)
868
1005
  .describe("When true, return the change plan without mutating state."),
869
1006
  },
1007
+ annotations: { idempotentHint: true },
1008
+ _meta: { idempotent: "true" },
870
1009
  }, async ({ post_id, status, date_gmt, dry_run }) => {
871
1010
  const body = {
872
1011
  status,
@@ -874,29 +1013,31 @@ registerPluginTool("diviops_page_update_status", {
874
1013
  };
875
1014
  if (date_gmt)
876
1015
  body.date_gmt = date_gmt;
877
- const result = await wp.request(`/page/update-status/${post_id}`, {
1016
+ const result = await wp.requestEnveloped(`/page/update-status/${post_id}`, {
878
1017
  method: "POST",
879
1018
  body,
880
1019
  });
881
1020
  return {
882
1021
  content: [
883
- { type: "text", text: JSON.stringify(result) },
1022
+ { type: "text", text: serializeEnvelope(result) },
884
1023
  ],
885
1024
  };
886
1025
  });
887
1026
  // ── Preset Tools ────────────────────────────────────────────────────
888
1027
  registerPluginTool("diviops_preset_audit", {
889
- description: "Audit all Divi presets (module + group). Each entry reports `block_ref_count` (page-content refs via modulePreset / groupPreset block markup), `group_ref_count` (in-registry chain refs from other presets — module presets via top-level `groupPresets.<slot>.presetId`, group presets via `attrs.groupPreset.<slot>.presetId`), and `referenced` (true if either > 0). Group presets that are chain-referenced also expose `referenced_by_presets` (UUIDs of the presets that wire them in — typically module presets, but type-agnostic). Use this before deleting — orphan-cleanup based only on page refs would silently wipe load-bearing chain-wired group presets (font, border, box-shadow, spacing, button). Also reports `orphan_default_pointers`: per-bucket `default` pointers that reference a UUID no longer present in `items[]` (caused by past unsafe deletes). Render-safe but blocks Divi's lazy recreate-on-VB-use path; clear via diviops_preset_set_default with unset=true on the affected module/group.",
1028
+ description: "Audit all Divi presets (module + group). Each entry reports `block_ref_count` (page-content refs via modulePreset / groupPreset block markup), `group_ref_count` (in-registry chain refs from other presets — module presets via top-level `groupPresets.<slot>.presetId`, group presets via `attrs.groupPreset.<slot>.presetId`), and `referenced` (true if either > 0). Group presets that are chain-referenced also expose `referenced_by_presets` (UUIDs of the presets that wire them in — typically module presets, but type-agnostic). Use this before deleting — orphan-cleanup based only on page refs would silently wipe load-bearing chain-wired group presets (font, border, box-shadow, spacing, button). Also reports `orphan_default_pointers`: per-bucket `default` pointers that reference a UUID no longer present in `items[]` (caused by past unsafe deletes). Render-safe but blocks Divi's lazy recreate-on-VB-use path; clear via diviops_preset_set_default with unset=true on the affected module/group. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
1029
+ annotations: { idempotentHint: true },
1030
+ _meta: { idempotent: "true" },
890
1031
  }, async () => {
891
- const result = await wp.request("/preset/audit");
1032
+ const result = await wp.requestEnveloped("/preset/audit");
892
1033
  return {
893
1034
  content: [
894
- { type: "text", text: JSON.stringify(result) },
1035
+ { type: "text", text: serializeEnvelope(result) },
895
1036
  ],
896
1037
  };
897
1038
  });
898
1039
  registerPluginTool("diviops_preset_cleanup", {
899
- description: 'Clean up presets. Default: remove spam presets. Optional: dedup=true to also remove duplicates, action="rename_strip_prefix" with prefix to strip a name prefix, or action="remove_orphans" with scope="spam"|"all" to remove unreferenced presets. Use dry_run: true (default) to preview.',
1040
+ description: 'Clean up presets. Default: remove spam presets. Optional: dedup=true to also remove duplicates, action="rename_strip_prefix" with prefix to strip a name prefix, or action="remove_orphans" with scope="spam"|"all" to remove unreferenced presets. Use dry_run: true (default) to preview. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }. Note: dry_run currently returns the route-specific summary shape rather than the standardized `data.plan = { summary, changes[] }` shape used by tools introduced after the dry_run convention was generalized; plan-shape standardization is tracked separately for the pre-existing dry_run wave.',
900
1041
  inputSchema: {
901
1042
  dry_run: z
902
1043
  .boolean()
@@ -921,6 +1062,8 @@ registerPluginTool("diviops_preset_cleanup", {
921
1062
  .default("spam")
922
1063
  .describe('Scope for remove_orphans: "spam" (only spam-named orphans) or "all" (all non-default orphans).'),
923
1064
  },
1065
+ annotations: { idempotentHint: false },
1066
+ _meta: { idempotent: "false" },
924
1067
  }, async ({ dry_run, dedup, action, prefix, scope }) => {
925
1068
  const body = { dry_run: dry_run ?? true };
926
1069
  if (dedup)
@@ -931,18 +1074,19 @@ registerPluginTool("diviops_preset_cleanup", {
931
1074
  body.prefix = prefix;
932
1075
  if (action === "remove_orphans" && scope)
933
1076
  body.scope = scope;
934
- const result = await wp.request("/preset/cleanup", {
1077
+ const result = await wp.requestEnveloped("/preset/cleanup", {
935
1078
  method: "POST",
936
1079
  body,
937
1080
  });
938
1081
  return {
939
1082
  content: [
940
- { type: "text", text: JSON.stringify(result) },
1083
+ { type: "text", text: serializeEnvelope(result) },
941
1084
  ],
942
1085
  };
943
1086
  });
944
1087
  registerPluginTool("diviops_preset_update", {
945
- description: "Update a specific preset by ID. Can rename, replace its style attributes, and/or change its stack priority. Note: Divi serves frontend CSS from a per-post static cache at wp-content/et-cache/{post_id}/ that wp cache flush does NOT invalidate — if you're verifying a preset change on the rendered frontend, delete that dir for affected pages to force regeneration. Server-side preset state updates immediately; only the pre-rendered CSS file is stale.",
1088
+ description: "Update a specific preset by ID. Can rename, replace its style attributes, and/or change its stack priority. Note: Divi serves frontend CSS from a per-post static cache at wp-content/et-cache/{post_id}/ that wp cache flush does NOT invalidate — if you're verifying a preset change on the rendered frontend, delete that dir for affected pages to force regeneration. Server-side preset state updates immediately; only the pre-rendered CSS file is stale. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing preset_id returns code 'not_found' with a hint to diviops_preset_audit." +
1089
+ DRY_RUN_DESC_SUFFIX,
946
1090
  inputSchema: {
947
1091
  preset_id: z.string().describe("Preset ID (UUID or short ID)"),
948
1092
  name: z.string().optional().describe("New display name for the preset"),
@@ -955,8 +1099,11 @@ registerPluginTool("diviops_preset_update", {
955
1099
  .int()
956
1100
  .optional()
957
1101
  .describe("Stack-merge priority. When this preset is part of a stacked-preset arrangement (e.g. base typography + brand override on the same module/group slot), Divi sorts presets ascending and merges in priority order, so a higher number wins the cascade. Default in Divi is 10 when omitted. Only meaningful for presets that participate in a stack — solo presets render the same regardless of priority."),
1102
+ dry_run: DRY_RUN_FIELD,
958
1103
  },
959
- }, async ({ preset_id, name, attrs, priority }) => {
1104
+ annotations: { idempotentHint: false },
1105
+ _meta: { idempotent: "conditional" },
1106
+ }, async ({ preset_id, name, attrs, priority, dry_run }) => {
960
1107
  const body = { preset_id };
961
1108
  if (name)
962
1109
  body.name = name;
@@ -964,18 +1111,20 @@ registerPluginTool("diviops_preset_update", {
964
1111
  body.attrs = attrs;
965
1112
  if (typeof priority === "number")
966
1113
  body.priority = priority;
967
- const result = await wp.request("/preset/update", {
1114
+ if (dry_run)
1115
+ body.dry_run = true;
1116
+ const result = await wp.requestEnveloped("/preset/update", {
968
1117
  method: "POST",
969
1118
  body,
970
1119
  });
971
1120
  return {
972
1121
  content: [
973
- { type: "text", text: JSON.stringify(result) },
1122
+ { type: "text", text: serializeEnvelope(result) },
974
1123
  ],
975
1124
  };
976
1125
  });
977
1126
  registerPluginTool("diviops_preset_delete", {
978
- description: "Delete a specific preset by ID. Use diviops_preset_audit first to verify the preset is unreferenced before deleting. Refuses with 409 preset_is_default if the target is the registered default for its module/group bucket — clear the pointer first via diviops_preset_set_default with unset=true, or pass force=true to delete and clear the pointer in one write.",
1127
+ description: "Delete a specific preset by ID. Use diviops_preset_audit first to verify the preset is unreferenced before deleting. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing preset_id returns code 'not_found' with a hint to diviops_preset_audit. Refuses with code 'conflict' (HTTP 409) and `error.data = { preset_id, type, module, name, reason: 'is_default' }` if the target is the registered default for its module/group bucket — clear the pointer first via diviops_preset_set_default with unset=true, or pass force=true to delete and clear the pointer in one write. The `reason` discriminator field leaves room for future conflict reasons (referenced_in_chain, etc.) without reshaping.",
979
1128
  inputSchema: {
980
1129
  preset_id: z.string().describe("Preset ID to delete"),
981
1130
  force: z
@@ -983,22 +1132,26 @@ registerPluginTool("diviops_preset_delete", {
983
1132
  .optional()
984
1133
  .describe("When true, deletes the preset even if it is the registered default and clears the default pointer in the same write. Default false (refuse-by-default)."),
985
1134
  },
1135
+ annotations: { idempotentHint: true },
1136
+ _meta: { idempotent: "true" },
986
1137
  }, async ({ preset_id, force }) => {
987
1138
  const body = { preset_id };
988
1139
  if (force !== undefined)
989
1140
  body.force = force;
990
- const result = await wp.request("/preset/delete", {
1141
+ const result = await wp.requestEnveloped("/preset/delete", {
991
1142
  method: "POST",
992
1143
  body,
993
1144
  });
994
1145
  return {
995
1146
  content: [
996
- { type: "text", text: JSON.stringify(result) },
1147
+ { type: "text", text: serializeEnvelope(result) },
997
1148
  ],
998
1149
  };
999
1150
  });
1000
1151
  registerPluginTool("diviops_preset_create", {
1001
- description: 'Create a new preset in the Divi 5 registry. For module presets, supply module_name (e.g. "divi/column", "divi/button", "divi/section"), name, and attrs. For group (attribute-level) presets, set type="group" and supply group_name ("divi/font", "divi/button", etc.), group_id ("designTitleText", "button", etc.), and optionally primary_attr_name.',
1152
+ description: 'Create a new preset in the Divi 5 registry. For module presets, supply module_name (e.g. "divi/column", "divi/button", "divi/section"), name, and attrs. For group (attribute-level) presets, set type="group" and supply group_name ("divi/font", "divi/button", etc.), group_id ("designTitleText", "button", etc.), and optionally primary_attr_name.' +
1153
+ DRY_RUN_DESC_SUFFIX +
1154
+ " NOTE: dry_run plan does not pre-allocate the UUID — that's generated at apply time. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }. Per-bucket name uniqueness check: a name collision in the same `(bucket, bucket_key)` returns code 'conflict' (HTTP 409) with `error.data = { existing_preset_id, bucket, bucket_key, name }` so callers can branch on reuse / rename / preset_update. Bucket coordinates are the natural addressing scope: a 'Hero Title' font preset and a 'Hero Title' button preset coexist (different buckets), but two 'Hero Title' presets under `group/divi/font` collide. Input-shape rejections (missing module_name/name/attrs, type outside [module,group], group preset without group_name/group_id) return code 'invalid_input' with structured `error.data` documenting the failed field.",
1002
1155
  inputSchema: {
1003
1156
  module_name: z
1004
1157
  .string()
@@ -1033,8 +1186,11 @@ registerPluginTool("diviops_preset_create", {
1033
1186
  .int()
1034
1187
  .optional()
1035
1188
  .describe("Stack-merge priority. When this preset participates in a stacked-preset arrangement, Divi sorts ascending and merges in priority order — higher number wins the cascade. Default in Divi is 10 when omitted."),
1189
+ dry_run: DRY_RUN_FIELD,
1036
1190
  },
1037
- }, async ({ module_name, name, attrs, type, group_name, group_id, primary_attr_name, make_default, priority }) => {
1191
+ annotations: { idempotentHint: false },
1192
+ _meta: { idempotent: "conditional" },
1193
+ }, async ({ module_name, name, attrs, type, group_name, group_id, primary_attr_name, make_default, priority, dry_run }) => {
1038
1194
  if (type === "group" && (!group_name || !group_id)) {
1039
1195
  throw new Error('type="group" requires both group_name and group_id. Example: group_name="divi/font", group_id="designTitleText".');
1040
1196
  }
@@ -1049,15 +1205,17 @@ registerPluginTool("diviops_preset_create", {
1049
1205
  body.make_default = true;
1050
1206
  if (typeof priority === "number")
1051
1207
  body.priority = priority;
1052
- const result = await wp.request("/preset/create", { method: "POST", body });
1208
+ if (dry_run)
1209
+ body.dry_run = true;
1210
+ const result = await wp.requestEnveloped("/preset/create", { method: "POST", body });
1053
1211
  return {
1054
1212
  content: [
1055
- { type: "text", text: JSON.stringify(result) },
1213
+ { type: "text", text: serializeEnvelope(result) },
1056
1214
  ],
1057
1215
  };
1058
1216
  });
1059
1217
  registerPluginTool("diviops_preset_reassign", {
1060
- description: 'Reassign a preset UUID across page content. Covers both module-level refs (`attrs.modulePreset[...]`) and attribute-level group-preset refs (`attrs.groupPreset.<slot>.presetId`), plus — for group presets — registry chain refs: module-bucket presets via top-level `groupPresets.<slot>.presetId`, group-bucket presets via `attrs.groupPreset.<slot>.presetId`. The `scope` param controls which ref types are walked (default "both", auto-selects based on new_uuid\'s bucket). Cross-bucket swaps (module ↔ group) are rejected. When `strip_inline=true` (default), strips inline attrs that duplicate the new preset\'s attrs (otherwise inline wins over preset): for module scope, strips from block root; for group scope, strips per-slot using Divi\'s own slot→target-path resolver (handles composite button groups, `-id-classes` suffix, FormField/checkbox/radio `attrName` mappings, cross-module translation). Both scopes enforce a singular-stack guard (skip strip when slot holds multiple presets). Unmappable group slots skip strip and emit a per-slot advisory at `summary.strip_advisory_per_slot[<module>::<slot>]`; neighbor slots are unaffected. Defaults to dry-run — set mode="apply" to actually rewrite. Use this to consolidate repeated inline styling into a reusable preset after creating one with diviops_preset_create.',
1218
+ description: 'Reassign a preset UUID across page content. Covers both module-level refs (`attrs.modulePreset[...]`) and attribute-level group-preset refs (`attrs.groupPreset.<slot>.presetId`), plus — for group presets — registry chain refs: module-bucket presets via top-level `groupPresets.<slot>.presetId`, group-bucket presets via `attrs.groupPreset.<slot>.presetId`. The `scope` param controls which ref types are walked (default "both", auto-selects based on new_uuid\'s bucket). Cross-bucket swaps (module ↔ group) are rejected with code \'preset.bucket_mismatch\' (HTTP 400) carrying `error.data = { old_bucket, new_bucket }`. Explicit scope mismatch with new_uuid\'s bucket returns code \'preset.scope_mismatch\' (HTTP 400) with `error.data = { scope, new_bucket }`. When `strip_inline=true` (default), strips inline attrs that duplicate the new preset\'s attrs (otherwise inline wins over preset): for module scope, strips from block root; for group scope, strips per-slot using Divi\'s own slot→target-path resolver (handles composite button groups, `-id-classes` suffix, FormField/checkbox/radio `attrName` mappings, cross-module translation). Both scopes enforce a singular-stack guard (skip strip when slot holds multiple presets). Unmappable group slots skip strip and emit a per-slot advisory at `summary.strip_advisory_per_slot[<module>::<slot>]`; neighbor slots are unaffected. Defaults to dry-run — set mode="apply" to actually rewrite. Use this to consolidate repeated inline styling into a reusable preset after creating one with diviops_preset_create. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing/invalid inputs return code \'invalid_input\' with structured `error.data` documenting the failed field; new_uuid not in registry returns code \'not_found\'; oversized page_ids batch returns code \'preset.too_many_pages\' with `error.data = { received, max_pages }`.',
1061
1219
  inputSchema: {
1062
1220
  old_uuid: z
1063
1221
  .string()
@@ -1085,6 +1243,8 @@ registerPluginTool("diviops_preset_reassign", {
1085
1243
  .default("both")
1086
1244
  .describe('"module" walks `attrs.modulePreset[...]` only. "group" walks `attrs.groupPreset.<slot>.presetId` plus registry chain refs (top-level `groupPresets.<slot>.presetId` on module presets, `attrs.groupPreset.<slot>.presetId` on group presets). "both" (default) auto-selects based on new_uuid\'s bucket — module/group identity is disjoint, so there is one valid walk per swap. An explicit "module" or "group" rejects if new_uuid is in the wrong bucket.'),
1087
1245
  },
1246
+ annotations: { idempotentHint: true },
1247
+ _meta: { idempotent: "true" },
1088
1248
  }, async ({ old_uuid, new_uuid, page_ids, mode, strip_inline, scope }) => {
1089
1249
  const body = {
1090
1250
  old_uuid,
@@ -1095,28 +1255,31 @@ registerPluginTool("diviops_preset_reassign", {
1095
1255
  };
1096
1256
  if (page_ids)
1097
1257
  body.page_ids = page_ids;
1098
- const result = await wp.request("/preset/reassign", {
1258
+ const result = await wp.requestEnveloped("/preset/reassign", {
1099
1259
  method: "POST",
1100
1260
  body,
1101
1261
  });
1102
1262
  return {
1103
1263
  content: [
1104
- { type: "text", text: JSON.stringify(result) },
1264
+ { type: "text", text: serializeEnvelope(result) },
1105
1265
  ],
1106
1266
  };
1107
1267
  });
1108
1268
  registerPluginTool("diviops_preset_scan_orphans", {
1109
- description: "Scan page content for modulePreset UUIDs that are not in the D5 registry. Categorizes as dangling orphans (preset was deleted, reference remains) or D4-legacy candidates (preset exists in the legacy builder_global_presets_ng option but not in D5). Use before diviops_preset_reassign to identify stale UUIDs for consolidation.",
1269
+ description: "Scan page content for modulePreset UUIDs that are not in the D5 registry. Categorizes as dangling orphans (preset was deleted, reference remains) or D4-legacy candidates (preset exists in the legacy builder_global_presets_ng option but not in D5). Use before diviops_preset_reassign to identify stale UUIDs for consolidation. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
1270
+ annotations: { idempotentHint: true },
1271
+ _meta: { idempotent: "true" },
1110
1272
  }, async () => {
1111
- const result = await wp.request("/preset/scan-orphans");
1273
+ const result = await wp.requestEnveloped("/preset/scan-orphans");
1112
1274
  return {
1113
1275
  content: [
1114
- { type: "text", text: JSON.stringify(result) },
1276
+ { type: "text", text: serializeEnvelope(result) },
1115
1277
  ],
1116
1278
  };
1117
1279
  });
1118
1280
  registerPluginTool("diviops_preset_set_default", {
1119
- description: "Set or clear the per-module/group default preset. Two addressing modes: (1) preset_id mode — walks both buckets to locate the preset by UUID, then points the containing module/group's `default` slot at it (or clears it with unset=true). (2) Bucket-addressed clear — pass type + module + unset=true to clear an orphan default pointer when the preset_id no longer exists in items[] (the preset_id walk path can't locate orphans — that's the very state being repaired; surfaced via diviops_preset_audit's `orphan_default_pointers`). Defaults apply to NEW module instances only — existing modules keep their current preset bindings (use diviops_preset_reassign for retroactive swaps). Use diviops_preset_audit's `is_default` and `orphan_default_pointers` fields to verify state before/after.",
1281
+ description: "Set or clear the per-module/group default preset. Two addressing modes: (1) preset_id mode — walks both buckets to locate the preset by UUID, then points the containing module/group's `default` slot at it (or clears it with unset=true). (2) Bucket-addressed clear — pass type + module + unset=true to clear an orphan default pointer when the preset_id no longer exists in items[] (the preset_id walk path can't locate orphans — that's the very state being repaired; surfaced via diviops_preset_audit's `orphan_default_pointers`). Defaults apply to NEW module instances only — existing modules keep their current preset bindings (use diviops_preset_reassign for retroactive swaps). Use diviops_preset_audit's `is_default` and `orphan_default_pointers` fields to verify state before/after. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing preset_id (and not in bucket-addressed-clear mode) / bucket-addressed mode without unset=true return code 'invalid_input'; missing preset / unknown bucket returns 'not_found'." +
1282
+ DRY_RUN_DESC_SUFFIX,
1120
1283
  inputSchema: {
1121
1284
  preset_id: z
1122
1285
  .string()
@@ -1134,8 +1297,11 @@ registerPluginTool("diviops_preset_set_default", {
1134
1297
  .boolean()
1135
1298
  .optional()
1136
1299
  .describe("If true, clear the default pointer. With preset_id, clears the bucket containing that preset. With type+module, clears that bucket directly (use this form for orphan-pointer repair). Defaults to false (set the preset as the default — preset_id required)."),
1300
+ dry_run: DRY_RUN_FIELD,
1137
1301
  },
1138
- }, async ({ preset_id, type, module, unset }) => {
1302
+ annotations: { idempotentHint: true },
1303
+ _meta: { idempotent: "true" },
1304
+ }, async ({ preset_id, type, module, unset, dry_run }) => {
1139
1305
  const body = {};
1140
1306
  if (preset_id !== undefined)
1141
1307
  body.preset_id = preset_id;
@@ -1145,19 +1311,21 @@ registerPluginTool("diviops_preset_set_default", {
1145
1311
  body.module = module;
1146
1312
  if (unset)
1147
1313
  body.unset = true;
1148
- const result = await wp.request("/preset/set-default", {
1314
+ if (dry_run)
1315
+ body.dry_run = true;
1316
+ const result = await wp.requestEnveloped("/preset/set-default", {
1149
1317
  method: "POST",
1150
1318
  body,
1151
1319
  });
1152
1320
  return {
1153
1321
  content: [
1154
- { type: "text", text: JSON.stringify(result) },
1322
+ { type: "text", text: serializeEnvelope(result) },
1155
1323
  ],
1156
1324
  };
1157
1325
  });
1158
1326
  // ── Library Tools ───────────────────────────────────────────────────
1159
1327
  registerPluginTool("diviops_library_list", {
1160
- description: "List saved Divi Library items. Filter by layout_type (section, row, module) and scope (global, non_global).",
1328
+ description: "List saved Divi Library items. Filter by layout_type (section, row, module) and scope (global, non_global). Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
1161
1329
  inputSchema: {
1162
1330
  layout_type: z
1163
1331
  .string()
@@ -1173,6 +1341,8 @@ registerPluginTool("diviops_library_list", {
1173
1341
  .default(50)
1174
1342
  .describe("Max results (default 50)"),
1175
1343
  },
1344
+ annotations: { idempotentHint: true },
1345
+ _meta: { idempotent: "true" },
1176
1346
  }, async ({ layout_type, scope, per_page }) => {
1177
1347
  const params = {};
1178
1348
  if (layout_type)
@@ -1181,28 +1351,31 @@ registerPluginTool("diviops_library_list", {
1181
1351
  params.scope = scope;
1182
1352
  if (per_page)
1183
1353
  params.per_page = String(per_page);
1184
- const result = await wp.request("/library/items", { params });
1354
+ const result = await wp.requestEnveloped("/library/items", { params });
1185
1355
  return {
1186
1356
  content: [
1187
- { type: "text", text: JSON.stringify(result) },
1357
+ { type: "text", text: serializeEnvelope(result) },
1188
1358
  ],
1189
1359
  };
1190
1360
  });
1191
1361
  registerPluginTool("diviops_library_get", {
1192
- description: "Get a Divi Library item's content by ID. Returns the raw block markup that can be used with diviops_section_append or diviops_page_update_content.",
1362
+ description: "Get a Divi Library item's content by ID. Returns the raw block markup that can be used with diviops_section_append or diviops_page_update_content. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing item_id returns ok:false with code 'not_found' and a hint pointing to diviops_library_list.",
1193
1363
  inputSchema: {
1194
1364
  item_id: z.number().describe("Library item ID"),
1195
1365
  },
1366
+ annotations: { idempotentHint: true },
1367
+ _meta: { idempotent: "true" },
1196
1368
  }, async ({ item_id }) => {
1197
- const result = await wp.request(`/library/item/${item_id}`);
1369
+ const result = await wp.requestEnveloped(`/library/item/${item_id}`);
1198
1370
  return {
1199
1371
  content: [
1200
- { type: "text", text: JSON.stringify(result) },
1372
+ { type: "text", text: serializeEnvelope(result) },
1201
1373
  ],
1202
1374
  };
1203
1375
  });
1204
1376
  registerPluginTool("diviops_library_save", {
1205
- description: 'Save Divi block markup to the Divi Library for reuse. Saved items appear in the VB\'s "Add From Library" panel.',
1377
+ description: 'Save Divi block markup to the Divi Library for reuse. Saved items appear in the VB\'s "Add From Library" panel. Title-uniqueness is enforced and scoped to (layout_type, scope) — a "Hero" section and a "Hero" row coexist (different design intent), but a second "Hero" section under the same scope returns ok:false with code \'conflict\' (HTTP 409) and `error.data = { existing_library_id, layout_type, scope }` so callers can retrieve the existing item and decide whether to reuse, rename, or delete-and-replace. Other rejections: missing title / non-string content / invalid layout_type or scope return \'invalid_input\'.' +
1378
+ DRY_RUN_DESC_SUFFIX,
1206
1379
  inputSchema: {
1207
1380
  title: z.string().describe("Display name for the library item"),
1208
1381
  content: z
@@ -1218,26 +1391,27 @@ registerPluginTool("diviops_library_save", {
1218
1391
  .optional()
1219
1392
  .default("non_global")
1220
1393
  .describe('"global" = synced across all uses, "non_global" = independent copies'),
1394
+ dry_run: DRY_RUN_FIELD,
1221
1395
  },
1222
- }, async ({ title, content, layout_type, scope }) => {
1223
- const result = await wp.request("/library/save", {
1396
+ annotations: { idempotentHint: false },
1397
+ _meta: { idempotent: "conditional" },
1398
+ }, async ({ title, content, layout_type, scope, dry_run }) => {
1399
+ const body = { title, content, layout_type, scope };
1400
+ if (dry_run)
1401
+ body.dry_run = true;
1402
+ const result = await wp.requestEnveloped("/library/save", {
1224
1403
  method: "POST",
1225
- body: {
1226
- title,
1227
- content,
1228
- layout_type,
1229
- scope,
1230
- },
1404
+ body,
1231
1405
  });
1232
1406
  return {
1233
1407
  content: [
1234
- { type: "text", text: JSON.stringify(result) },
1408
+ { type: "text", text: serializeEnvelope(result) },
1235
1409
  ],
1236
1410
  };
1237
1411
  });
1238
1412
  // ── Theme Builder Tools ─────────────────────────────────────────────
1239
1413
  registerPluginTool("diviops_tb_template_list", {
1240
- description: "List all Theme Builder templates with their conditions, layout IDs, and enabled status. Shows which template applies to which pages/post types.",
1414
+ description: "List all Theme Builder templates with their conditions, layout IDs, and enabled status. Shows which template applies to which pages/post types. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
1241
1415
  inputSchema: {
1242
1416
  per_page: z
1243
1417
  .number()
@@ -1247,53 +1421,65 @@ registerPluginTool("diviops_tb_template_list", {
1247
1421
  .describe("Results per page (max 100)"),
1248
1422
  page: z.number().optional().default(1).describe("Page number"),
1249
1423
  },
1424
+ annotations: { idempotentHint: true },
1425
+ _meta: { idempotent: "true" },
1250
1426
  }, async ({ per_page, page }) => {
1251
1427
  const params = {};
1252
1428
  if (per_page)
1253
1429
  params.per_page = String(per_page);
1254
1430
  if (page)
1255
1431
  params.page = String(page);
1256
- const result = await wp.request("/theme-builder/template/list", { params });
1432
+ const result = await wp.requestEnveloped("/theme-builder/template/list", { params });
1257
1433
  return {
1258
1434
  content: [
1259
- { type: "text", text: JSON.stringify(result) },
1435
+ { type: "text", text: serializeEnvelope(result) },
1260
1436
  ],
1261
1437
  };
1262
1438
  });
1263
1439
  registerPluginTool("diviops_tb_layout_get", {
1264
- description: "Get a Theme Builder layout's block markup content (header, body, or footer). Use the layout IDs from diviops_tb_template_list.",
1440
+ description: "Get a Theme Builder layout's block markup content (header, body, or footer). Use the layout IDs from diviops_tb_template_list. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing layout_id returns ok:false with code 'not_found' and a hint pointing to diviops_tb_template_list.",
1265
1441
  inputSchema: {
1266
1442
  layout_id: z
1267
1443
  .number()
1268
1444
  .describe("Layout post ID (from template header_layout_id, body_layout_id, or footer_layout_id)"),
1269
1445
  },
1446
+ annotations: { idempotentHint: true },
1447
+ _meta: { idempotent: "true" },
1270
1448
  }, async ({ layout_id }) => {
1271
- const result = await wp.request(`/theme-builder/layout/get/${layout_id}`);
1449
+ const result = await wp.requestEnveloped(`/theme-builder/layout/get/${layout_id}`);
1272
1450
  return {
1273
1451
  content: [
1274
- { type: "text", text: JSON.stringify(result) },
1452
+ { type: "text", text: serializeEnvelope(result) },
1275
1453
  ],
1276
1454
  };
1277
1455
  });
1278
1456
  registerPluginTool("diviops_tb_layout_update", {
1279
- description: "Update a Theme Builder layout's block markup (header, body, or footer). Replaces the full content.",
1457
+ description: "Update a Theme Builder layout's block markup (header, body, or footer). Replaces the full content. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing layout_id returns ok:false with code 'not_found'." +
1458
+ DRY_RUN_DESC_SUFFIX,
1280
1459
  inputSchema: {
1281
1460
  layout_id: z.number().describe("Layout post ID to update"),
1282
1461
  content: z.string().describe("New block markup content"),
1462
+ dry_run: DRY_RUN_FIELD,
1283
1463
  },
1284
- }, async ({ layout_id, content }) => {
1285
- const result = await wp.request(`/theme-builder/layout/update/${layout_id}`, {
1464
+ annotations: { idempotentHint: false },
1465
+ _meta: { idempotent: "conditional" },
1466
+ }, async ({ layout_id, content, dry_run }) => {
1467
+ const body = { content };
1468
+ if (dry_run)
1469
+ body.dry_run = true;
1470
+ const result = await wp.requestEnveloped(`/theme-builder/layout/update/${layout_id}`, {
1286
1471
  method: "PUT",
1287
- body: { content },
1472
+ body,
1288
1473
  });
1289
1474
  return {
1290
1475
  content: [
1291
- { type: "text", text: JSON.stringify(result) },
1476
+ { type: "text", text: serializeEnvelope(result) },
1292
1477
  ],
1293
1478
  };
1294
1479
  });
1295
1480
  registerPluginTool("diviops_tb_template_create", {
1296
- description: "Create a Theme Builder template with custom header and/or footer. Automatically creates layout posts, sets conditions, and links to Theme Builder.",
1481
+ description: "Create a Theme Builder template with custom header and/or footer. Automatically creates layout posts, sets conditions, and links to Theme Builder. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing Theme Builder master post returns ok:false with code 'wp_error' and a hint to open the Divi Theme Builder once to initialize it." +
1482
+ DRY_RUN_DESC_SUFFIX,
1297
1483
  inputSchema: {
1298
1484
  title: z.string().describe('Template name (e.g. "Landing Pages")'),
1299
1485
  condition: z
@@ -1309,21 +1495,28 @@ registerPluginTool("diviops_tb_template_create", {
1309
1495
  .optional()
1310
1496
  .default("")
1311
1497
  .describe("Footer block markup (empty = inherit from default template)"),
1498
+ dry_run: DRY_RUN_FIELD,
1312
1499
  },
1313
- }, async ({ title, condition, header_content, footer_content }) => {
1314
- const result = await wp.request("/theme-builder/template/create", {
1500
+ annotations: { idempotentHint: false },
1501
+ _meta: { idempotent: "false" },
1502
+ }, async ({ title, condition, header_content, footer_content, dry_run }) => {
1503
+ const body = { title, condition, header_content, footer_content };
1504
+ if (dry_run)
1505
+ body.dry_run = true;
1506
+ const result = await wp.requestEnveloped("/theme-builder/template/create", {
1315
1507
  method: "POST",
1316
- body: { title, condition, header_content, footer_content },
1508
+ body,
1317
1509
  });
1318
1510
  return {
1319
1511
  content: [
1320
- { type: "text", text: JSON.stringify(result) },
1512
+ { type: "text", text: serializeEnvelope(result) },
1321
1513
  ],
1322
1514
  };
1323
1515
  });
1324
1516
  // ── Canvas Tools ────────────────────────────────────────────────────
1325
1517
  registerPluginTool("diviops_canvas_create", {
1326
- description: "Create a canvas (off-canvas workspace) linked to a page. Used for popups, off-canvas menus, modals. Content uses standard Divi block markup.",
1518
+ description: "Create a canvas (off-canvas workspace) linked to a page. Used for popups, off-canvas menus, modals. Content uses standard Divi block markup. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing parent_page_id returns ok:false with code 'not_found'; non-string content / malformed canvas_id / append_to_main outside {above, below} returns 'invalid_input'." +
1519
+ DRY_RUN_DESC_SUFFIX,
1327
1520
  inputSchema: {
1328
1521
  title: z
1329
1522
  .string()
@@ -1346,8 +1539,11 @@ registerPluginTool("diviops_canvas_create", {
1346
1539
  .number()
1347
1540
  .optional()
1348
1541
  .describe("Layering order (higher = on top)"),
1542
+ dry_run: DRY_RUN_FIELD,
1349
1543
  },
1350
- }, async ({ title, parent_page_id, content, canvas_id, append_to_main, z_index, }) => {
1544
+ annotations: { idempotentHint: false },
1545
+ _meta: { idempotent: "false" },
1546
+ }, async ({ title, parent_page_id, content, canvas_id, append_to_main, z_index, dry_run, }) => {
1351
1547
  const body = {
1352
1548
  title,
1353
1549
  parent_page_id,
@@ -1359,15 +1555,17 @@ registerPluginTool("diviops_canvas_create", {
1359
1555
  body.append_to_main = append_to_main;
1360
1556
  if (z_index !== undefined)
1361
1557
  body.z_index = z_index;
1362
- const result = await wp.request("/canvas/create", { method: "POST", body });
1558
+ if (dry_run)
1559
+ body.dry_run = true;
1560
+ const result = await wp.requestEnveloped("/canvas/create", { method: "POST", body });
1363
1561
  return {
1364
1562
  content: [
1365
- { type: "text", text: JSON.stringify(result) },
1563
+ { type: "text", text: serializeEnvelope(result) },
1366
1564
  ],
1367
1565
  };
1368
1566
  });
1369
1567
  registerPluginTool("diviops_canvas_list", {
1370
- description: "List canvases (off-canvas workspaces). Filter by parent page or list all.",
1568
+ description: "List canvases (off-canvas workspaces). Filter by parent page or list all. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
1371
1569
  inputSchema: {
1372
1570
  parent_page_id: z
1373
1571
  .number()
@@ -1382,36 +1580,41 @@ registerPluginTool("diviops_canvas_list", {
1382
1580
  .default(50)
1383
1581
  .describe("Max results (default 50, 1-100)"),
1384
1582
  },
1583
+ annotations: { idempotentHint: true },
1584
+ _meta: { idempotent: "true" },
1385
1585
  }, async ({ parent_page_id, per_page }) => {
1386
1586
  const params = {};
1387
1587
  if (parent_page_id)
1388
1588
  params.parent_page_id = String(parent_page_id);
1389
1589
  if (per_page)
1390
1590
  params.per_page = String(per_page);
1391
- const result = await wp.request("/canvas/list", { params });
1591
+ const result = await wp.requestEnveloped("/canvas/list", { params });
1392
1592
  return {
1393
1593
  content: [
1394
- { type: "text", text: JSON.stringify(result) },
1594
+ { type: "text", text: serializeEnvelope(result) },
1395
1595
  ],
1396
1596
  };
1397
1597
  });
1398
1598
  registerPluginTool("diviops_canvas_get", {
1399
- description: "Get a canvas's block content and metadata.",
1599
+ description: "Get a canvas's block content and metadata. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing canvas_post_id returns ok:false with code 'not_found' and a hint pointing to diviops_canvas_list.",
1400
1600
  inputSchema: {
1401
1601
  canvas_post_id: z
1402
1602
  .number()
1403
1603
  .describe("Canvas post ID (from diviops_canvas_list)"),
1404
1604
  },
1605
+ annotations: { idempotentHint: true },
1606
+ _meta: { idempotent: "true" },
1405
1607
  }, async ({ canvas_post_id }) => {
1406
- const result = await wp.request(`/canvas/get/${canvas_post_id}`);
1608
+ const result = await wp.requestEnveloped(`/canvas/get/${canvas_post_id}`);
1407
1609
  return {
1408
1610
  content: [
1409
- { type: "text", text: JSON.stringify(result) },
1611
+ { type: "text", text: serializeEnvelope(result) },
1410
1612
  ],
1411
1613
  };
1412
1614
  });
1413
1615
  registerPluginTool("diviops_canvas_update", {
1414
- description: "Update a canvas's content and/or metadata. Pass any subset of fields — e.g. `{canvas_post_id, title}` to rename without touching content. `content` replaces the entire canvas when present. At least one of content/title/append_to_main/z_index is required.",
1616
+ description: "Update a canvas's content and/or metadata. Pass any subset of fields — e.g. `{canvas_post_id, title}` to rename without touching content. `content` replaces the entire canvas when present. At least one of content/title/append_to_main/z_index is required. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing canvas_post_id returns ok:false with code 'not_found'; empty / no-op payload (no content/title/append_to_main/z_index) returns 'invalid_input' with a hint pointing at the rename-only path." +
1617
+ DRY_RUN_DESC_SUFFIX,
1415
1618
  inputSchema: {
1416
1619
  canvas_post_id: z.number().describe("Canvas post ID"),
1417
1620
  content: z
@@ -1424,8 +1627,11 @@ registerPluginTool("diviops_canvas_update", {
1424
1627
  .optional()
1425
1628
  .describe('Append position: "above", "below", or "" to clear'),
1426
1629
  z_index: z.number().optional().describe("Layering order"),
1630
+ dry_run: DRY_RUN_FIELD,
1427
1631
  },
1428
- }, async ({ canvas_post_id, content, title, append_to_main, z_index }) => {
1632
+ annotations: { idempotentHint: false },
1633
+ _meta: { idempotent: "conditional" },
1634
+ }, async ({ canvas_post_id, content, title, append_to_main, z_index, dry_run }) => {
1429
1635
  const body = {};
1430
1636
  if (content !== undefined)
1431
1637
  body.content = content;
@@ -1435,18 +1641,20 @@ registerPluginTool("diviops_canvas_update", {
1435
1641
  body.append_to_main = append_to_main;
1436
1642
  if (z_index !== undefined)
1437
1643
  body.z_index = z_index;
1438
- const result = await wp.request(`/canvas/update/${canvas_post_id}`, {
1644
+ if (dry_run)
1645
+ body.dry_run = true;
1646
+ const result = await wp.requestEnveloped(`/canvas/update/${canvas_post_id}`, {
1439
1647
  method: "POST",
1440
1648
  body,
1441
1649
  });
1442
1650
  return {
1443
1651
  content: [
1444
- { type: "text", text: JSON.stringify(result) },
1652
+ { type: "text", text: serializeEnvelope(result) },
1445
1653
  ],
1446
1654
  };
1447
1655
  });
1448
1656
  registerPluginTool("diviops_canvas_duplicate", {
1449
- description: "Deep-copy a canvas (post_content + canvas-specific meta: parent page, append_to_main, z_index). Source canvas untouched. Default copy title is `<source title> (Copy)` with auto-suffix on collision (Copy 2, Copy 3, …) — use this for repeat-clone workflows. Pass an explicit `title` for a deliberate name; collisions return 409 instead of silently auto-suffixing. Pass `dry_run: true` to preview without mutating.",
1657
+ description: "Deep-copy a canvas (post_content + canvas-specific meta: parent page, append_to_main, z_index). Source canvas untouched. Default copy title is `<source title> (Copy)` with auto-suffix on collision (Copy 2, Copy 3, …) — use this for repeat-clone workflows. Pass an explicit `title` for a deliberate name; collisions return ok:false with code 'conflict' (HTTP 409) and `error.data = { existing_canvas_id, parent_page_id }` so callers can retrieve / rename the conflicting canvas. Pass `dry_run: true` to preview without mutating. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing canvas_post_id returns 'not_found'.",
1450
1658
  inputSchema: {
1451
1659
  canvas_post_id: z.number().describe("Source canvas post ID"),
1452
1660
  title: z
@@ -1459,59 +1667,145 @@ registerPluginTool("diviops_canvas_duplicate", {
1459
1667
  .default(false)
1460
1668
  .describe("When true, return the change plan without creating the canvas."),
1461
1669
  },
1670
+ annotations: { idempotentHint: false },
1671
+ _meta: { idempotent: "conditional" },
1462
1672
  }, async ({ canvas_post_id, title, dry_run }) => {
1463
1673
  const body = { dry_run: dry_run ?? false };
1464
1674
  if (title !== undefined)
1465
1675
  body.title = title;
1466
- const result = await wp.request(`/canvas/duplicate/${canvas_post_id}`, {
1676
+ const result = await wp.requestEnveloped(`/canvas/duplicate/${canvas_post_id}`, {
1467
1677
  method: "POST",
1468
1678
  body,
1469
1679
  });
1470
1680
  return {
1471
1681
  content: [
1472
- { type: "text", text: JSON.stringify(result) },
1682
+ { type: "text", text: serializeEnvelope(result) },
1473
1683
  ],
1474
1684
  };
1475
1685
  });
1476
1686
  registerPluginTool("diviops_canvas_delete", {
1477
- description: "Delete a canvas. This permanently removes the canvas post.",
1687
+ description: "Delete a canvas. This permanently removes the canvas post. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; missing canvas_post_id returns ok:false with code 'not_found'." +
1688
+ DRY_RUN_DESC_SUFFIX,
1478
1689
  inputSchema: {
1479
1690
  canvas_post_id: z.number().describe("Canvas post ID to delete"),
1691
+ dry_run: DRY_RUN_FIELD,
1480
1692
  },
1481
- }, async ({ canvas_post_id }) => {
1482
- const result = await wp.request(`/canvas/delete/${canvas_post_id}`, {
1693
+ annotations: { idempotentHint: true },
1694
+ _meta: { idempotent: "true" },
1695
+ }, async ({ canvas_post_id, dry_run }) => {
1696
+ const body = {};
1697
+ if (dry_run)
1698
+ body.dry_run = true;
1699
+ const result = await wp.requestEnveloped(`/canvas/delete/${canvas_post_id}`, {
1483
1700
  method: "POST",
1701
+ body,
1484
1702
  });
1485
1703
  return {
1486
1704
  content: [
1487
- { type: "text", text: JSON.stringify(result) },
1705
+ { type: "text", text: serializeEnvelope(result) },
1488
1706
  ],
1489
1707
  };
1490
1708
  });
1491
1709
  // ── WP-CLI ──────────────────────────────────────────────────────────
1492
1710
  server.registerTool("diviops_meta_wp_cli", {
1493
- description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel), or WP_CLI_CMD for containerized wrappers. Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info/core/db, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF/SCF schema ops (`acf export/import/field-group list/get` plus SCF 6.8.4+ `scf json {status,sync,import,export}` and the `acf json …` aliases), and WXR export. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var) adds destructive or bulk-modifying ops: option update, post/post meta/term delete, search-replace, import, plugin activate/deactivate, eval-file. Filesystem-touching commands (`wp export`, `acf export/import`, `scf|acf json export/import`) are additionally constrained: path arguments must resolve under a safe root (defaults to `<WP_PATH>/.diviops-tmp/`, overridable via DIVIOPS_WP_CLI_SAFE_FS_ROOT, disable via DIVIOPS_WP_CLI_UNSAFE_FS=1); `wp export` and `scf json export` require an explicit `--dir=<path>` (or `--stdout`). In WP_CLI_CMD wrapper mode, DIVIOPS_WP_CLI_SAFE_FS_ROOT is required for FS-sensitive commands. Prefer the typed `diviops_scf_*` wrappers for SCF round-trips — they're easier to invoke and accept the same safe-root scoping. Use --format=json for structured output. Full allowlist + tier rationale + filesystem semantics in the MCP server README.",
1711
+ description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel), or WP_CLI_CMD for containerized wrappers. Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info/core/db, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF/SCF schema ops (`acf export/import/field-group list/get` plus SCF 6.8.4+ `scf json {status,sync,import,export}` and the `acf json …` aliases), and WXR export. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var) adds destructive or bulk-modifying ops: option update, post/post meta/term delete, search-replace, import, plugin activate/deactivate, eval-file. Filesystem-touching commands (`wp export`, `acf export/import`, `scf|acf json export/import`) are additionally constrained: path arguments must resolve under a safe root (defaults to `<WP_PATH>/.diviops-tmp/`, overridable via DIVIOPS_WP_CLI_SAFE_FS_ROOT, disable via DIVIOPS_WP_CLI_UNSAFE_FS=1); `wp export` and `scf json export` require an explicit `--dir=<path>` (or `--stdout`). In WP_CLI_CMD wrapper mode, DIVIOPS_WP_CLI_SAFE_FS_ROOT is required for FS-sensitive commands. Prefer the typed `diviops_scf_*` wrappers for SCF round-trips — they're easier to invoke and accept the same safe-root scoping. Use --format=json for structured output. Full allowlist + tier rationale + filesystem semantics in the MCP server README. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }. Success payload: { stdout: string, stderr: string, exit_code: 0 }. Four failure modes converge on 'meta_wp_cli.command_failed' with error.data = { exit_code: number | null, stdout: string, stderr: string }: (a) numeric exit_code — wp-cli ran and exited non-zero; stdout/stderr are raw streams verbatim. (b) exit_code=null and message starts with 'wp-cli command terminated:' — execFile launched the child but it was killed (timeout or signal); stdout/stderr carry whatever streamed before the kill. (c) exit_code=null and message starts with 'wp-cli could not spawn:' — the OS refused to start the child (ENOENT/EACCES/EPERM); child never ran, stdout/stderr are empty. (d) exit_code=null and message is the rejection reason — pre-execution rejection by the allowlist / FS validator; rejection reason synthesized into error.data.stderr because the child never ran. A missing wp-cli configuration surfaces as 'meta_wp_cli.not_configured'. stdout is always passed through as a string (no server-side JSON parse) — pass --format=json and parse on the caller side when you want structured output.",
1494
1712
  inputSchema: {
1495
1713
  command: z
1496
1714
  .string()
1497
1715
  .describe('WP-CLI command without the "wp" prefix. E.g. "option get blogname", "post list --format=json", "export --dir=$DIVIOPS_WP_CLI_SAFE_FS_ROOT --filename_format={site}.{date}.xml"'),
1498
1716
  },
1717
+ annotations: { idempotentHint: false },
1718
+ _meta: { idempotent: "conditional" },
1499
1719
  }, async ({ command }) => {
1500
- if (!wpCli) {
1720
+ const response = await wrapResponse(async () => {
1721
+ if (!wpCli) {
1722
+ withCode("meta_wp_cli.not_configured", "WP-CLI not configured.", 'Set the WP_PATH environment variable to your WordPress installation path. Example: claude mcp add diviops-mcp -- env WP_URL=http://site.local WP_USER=admin WP_APP_PASSWORD="xxxx" WP_PATH="/Users/you/Local Sites/your-site/app/public" npx @diviops/mcp-server. Local site ID is auto-detected from WP_PATH; set LOCAL_SITE_ID explicitly if needed.');
1723
+ }
1724
+ const result = await wpCli.run(command);
1725
+ if (!result.success) {
1726
+ // Four failure shapes converge on `meta_wp_cli.command_failed`,
1727
+ // discriminated by `result.failureKind` from the runner:
1728
+ // - 'exited': wp-cli ran and returned a numeric exit code.
1729
+ // stdout/stderr are raw streams verbatim
1730
+ // (empty string when wp-cli emitted nothing).
1731
+ // The exit-code summary lives on `error.message`
1732
+ // so callers branch on `error.data.exit_code`
1733
+ // rather than parsing the stream.
1734
+ // - 'killed': execFile spawned the child but it was killed
1735
+ // (timeout or signal). exit_code is null
1736
+ // because a numeric code is unavailable, but
1737
+ // stdout/stderr carry whatever streamed before
1738
+ // the kill — surface them verbatim. The kill
1739
+ // reason lives on `error.message` and `hint`
1740
+ // so callers can distinguish "timed out" from
1741
+ // "got partial output then bailed."
1742
+ // - 'spawn_failed': execFile invoked but the OS refused to start
1743
+ // the child (ENOENT, EACCES, EPERM, etc.). The
1744
+ // child never ran; stdout/stderr are empty.
1745
+ // Distinct from 'killed' — fix path is
1746
+ // environmental (PATH, install, perms), not
1747
+ // "raise the timeout." The system errno lives
1748
+ // in `result.error` so callers can identify the
1749
+ // specific OS reason without parsing.
1750
+ // - 'rejected': pre-execution rejection (allowlist / FS
1751
+ // validator). Child never ran, `result.stderr`
1752
+ // always empty — synthesize from `result.error`
1753
+ // so callers see a uniform
1754
+ // `{ exit_code, stdout, stderr }` shape.
1755
+ //
1756
+ // Codex review history:
1757
+ // pass 1 — collapsed 'killed' onto 'rejected' (both share
1758
+ // exit_code: null), causing timeouts to mis-emit
1759
+ // pre-execution rejection hints. Fixed in a33ed7c.
1760
+ // pass 2 — collapsed 'spawn_failed' (ENOENT etc.) onto 'killed',
1761
+ // telling callers the child was launched and killed
1762
+ // even though it never spawned. This branch.
1763
+ const detail = result.error ?? "wp-cli command failed";
1764
+ const kind = result.failureKind ?? "exited";
1765
+ let message;
1766
+ let hint;
1767
+ let stderrForData;
1768
+ if (kind === "rejected") {
1769
+ message = detail;
1770
+ hint =
1771
+ "Command was rejected before execution. Common causes: not in the allowlist (see DIVIOPS_WP_CLI_ALLOW for opt-ins) or filesystem path outside DIVIOPS_WP_CLI_SAFE_FS_ROOT.";
1772
+ stderrForData = detail;
1773
+ }
1774
+ else if (kind === "spawn_failed") {
1775
+ message = `wp-cli could not spawn: ${detail}`;
1776
+ hint =
1777
+ "The OS refused to start the wp-cli executable — common causes: WP_CLI_CMD points at a missing binary (ENOENT), the binary is not executable (EACCES), or PATH does not include wp-cli. Verify `which wp` (or your WP_CLI_CMD prefix) resolves and is executable. error.data.stdout / error.data.stderr are empty because the child never ran.";
1778
+ stderrForData = detail;
1779
+ }
1780
+ else if (kind === "killed") {
1781
+ message = `wp-cli command terminated: ${detail}`;
1782
+ hint =
1783
+ "Command was launched but killed before it finished (timeout or signal). error.data.stdout / error.data.stderr carry whatever streamed before the kill. Consider raising the timeout or splitting the command into smaller batches.";
1784
+ stderrForData = result.stderr;
1785
+ }
1786
+ else {
1787
+ message = `wp-cli exited with code ${result.exitCode}`;
1788
+ hint =
1789
+ "Inspect error.data.stderr for the failure reason; re-run with WP_CLI_DEBUG=1 in the env to surface PHP traceback.";
1790
+ stderrForData = result.stderr;
1791
+ }
1792
+ withCode("meta_wp_cli.command_failed", message, hint, {
1793
+ exit_code: result.exitCode,
1794
+ stdout: result.stdout,
1795
+ stderr: stderrForData,
1796
+ });
1797
+ }
1501
1798
  return {
1502
- content: [
1503
- {
1504
- type: "text",
1505
- text: 'WP-CLI not configured. Set the WP_PATH environment variable to your WordPress installation path.\n\nExample:\n claude mcp add diviops-mcp -- env WP_URL=http://site.local WP_USER=admin WP_APP_PASSWORD="xxxx" WP_PATH="/Users/you/Local Sites/your-site/app/public" npx @diviops/mcp-server\n\nThe Local by Flywheel site ID is auto-detected from WP_PATH. Set LOCAL_SITE_ID explicitly if auto-detection fails.',
1506
- },
1507
- ],
1799
+ stdout: result.stdout,
1800
+ stderr: result.stderr,
1801
+ exit_code: 0,
1508
1802
  };
1509
- }
1510
- const result = await wpCli.run(command);
1511
- const output = result.success
1512
- ? result.output
1513
- : `Error: ${result.error}\n${result.output}`;
1514
- return { content: [{ type: "text", text: output }] };
1803
+ });
1804
+ return {
1805
+ content: [
1806
+ { type: "text", text: serializeEnvelope(response) },
1807
+ ],
1808
+ };
1515
1809
  });
1516
1810
  // ── SCF (Secure Custom Fields / ACF) wrappers ───────────────────────
1517
1811
  //
@@ -1519,15 +1813,25 @@ server.registerTool("diviops_meta_wp_cli", {
1519
1813
  // CLI family (also reachable as `wp acf json …`). The plugin file at
1520
1814
  // wp-content/plugins/secure-custom-fields/src/CLI/JsonCommand.php is the
1521
1815
  // upstream source of truth for flag shapes — keep these wrappers aligned.
1522
- function ensureWpCli() {
1816
+ //
1817
+ // Envelope adoption: every tool wraps its handler in `wrapResponse` +
1818
+ // `serializeEnvelope`. wp-cli failures route through `failScfCommand`
1819
+ // which mirrors `meta_wp_cli.command_failed`'s four-failureKind shape but
1820
+ // emits a namespace-prefixed `scf.command_failed` code so callers can
1821
+ // branch on `error.code` without reading `error.data` to know whether the
1822
+ // failed call was `wp scf json …` or `wp post …`.
1823
+ /**
1824
+ * Short-circuit when wp-cli isn't configured. Throws via `withCode` so the
1825
+ * surrounding `wrapResponse` emits the standard envelope. Adopted from the
1826
+ * `meta_wp_cli` precedent (`meta_wp_cli.not_configured`); reuses the
1827
+ * namespace-prefixed pattern as `scf.not_configured` so callers can
1828
+ * branch on `error.code` without inspecting message strings.
1829
+ */
1830
+ function ensureScfWpCli() {
1523
1831
  if (!wpCli) {
1524
- return {
1525
- ok: false,
1526
- text: "WP-CLI not configured. Set WP_PATH (Local by Flywheel auto-detect) " +
1527
- "or WP_CLI_CMD (containerized wrappers) to enable SCF round-trip tools.",
1528
- };
1832
+ withCode("scf.not_configured", "WP-CLI not configured.", "Set WP_PATH (Local by Flywheel auto-detect) or WP_CLI_CMD (containerized wrappers) to enable SCF round-trip tools.");
1529
1833
  }
1530
- return { ok: true };
1834
+ return wpCli;
1531
1835
  }
1532
1836
  function pushScfFlag(args, name, value) {
1533
1837
  if (!value)
@@ -1538,8 +1842,60 @@ function pushScfFlag(args, name, value) {
1538
1842
  // spaces flow through verbatim.
1539
1843
  args.push(`--${name}=${value}`);
1540
1844
  }
1845
+ /**
1846
+ * Mirror of `meta_wp_cli.command_failed`'s four-failureKind branch logic,
1847
+ * scoped to the scf_* namespace. Inputs:
1848
+ * - `result`: the raw `wpCli.runArgs(...)` payload (success === false here)
1849
+ * - `args`: the wp-cli argv (sanitized of secrets at the wrapper level —
1850
+ * SCF args carry no credentials) so callers can see exactly what was
1851
+ * attempted
1852
+ *
1853
+ * Throws via `withCode` so the surrounding `wrapResponse` emits the
1854
+ * standard envelope with code `scf.command_failed`. `error.data` mirrors
1855
+ * meta_wp_cli's shape verbatim (`{ exit_code, stdout, stderr, failure_kind,
1856
+ * command }`) — see tools.md "Response shape" for the four failure_kind
1857
+ * branches and the matching hints.
1858
+ */
1859
+ function failScfCommand(result, args) {
1860
+ const detail = result.error ?? "wp-cli command failed";
1861
+ const kind = result.failureKind ?? "exited";
1862
+ let message;
1863
+ let hint;
1864
+ let stderrForData;
1865
+ if (kind === "rejected") {
1866
+ message = detail;
1867
+ hint =
1868
+ "Command was rejected before execution. Common causes: not in the allowlist (see DIVIOPS_WP_CLI_ALLOW for opt-ins) or filesystem path outside DIVIOPS_WP_CLI_SAFE_FS_ROOT.";
1869
+ stderrForData = detail;
1870
+ }
1871
+ else if (kind === "spawn_failed") {
1872
+ message = `wp-cli could not spawn: ${detail}`;
1873
+ hint =
1874
+ "The OS refused to start the wp-cli executable — common causes: WP_CLI_CMD points at a missing binary (ENOENT), the binary is not executable (EACCES), or PATH does not include wp-cli. Verify `which wp` (or your WP_CLI_CMD prefix) resolves and is executable. error.data.stdout / error.data.stderr are empty because the child never ran.";
1875
+ stderrForData = detail;
1876
+ }
1877
+ else if (kind === "killed") {
1878
+ message = `wp-cli command terminated: ${detail}`;
1879
+ hint =
1880
+ "Command was launched but killed before it finished (timeout or signal). error.data.stdout / error.data.stderr carry whatever streamed before the kill. Consider raising the timeout or splitting the command into smaller batches.";
1881
+ stderrForData = result.stderr;
1882
+ }
1883
+ else {
1884
+ message = `wp-cli exited with code ${result.exitCode}`;
1885
+ hint =
1886
+ "Inspect error.data.stderr for the failure reason; re-run with WP_CLI_DEBUG=1 in the env to surface PHP traceback.";
1887
+ stderrForData = result.stderr;
1888
+ }
1889
+ withCode("scf.command_failed", message, hint, {
1890
+ exit_code: result.exitCode,
1891
+ stdout: result.stdout,
1892
+ stderr: stderrForData,
1893
+ failure_kind: kind,
1894
+ command: [...args],
1895
+ });
1896
+ }
1541
1897
  server.registerTool("diviops_scf_status", {
1542
- description: "Show SCF (Secure Custom Fields) sync status — how many field groups, post types, taxonomies, and options pages have JSON-on-disk newer than the database (or absent from DB). Read-only. Wraps `wp scf json status`. Requires SCF 6.8.4+ and WP_PATH or WP_CLI_CMD.",
1898
+ description: "Show SCF (Secure Custom Fields) sync status — how many field groups, post types, taxonomies, and options pages have JSON-on-disk newer than the database (or absent from DB). Read-only. Wraps `wp scf json status`. Requires SCF 6.8.4+ and WP_PATH or WP_CLI_CMD. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { stdout: string, stderr: string }. wp-cli failures map to 'scf.command_failed' with `error.data = { exit_code, stdout, stderr, failure_kind, command }` (four failure_kind branches: 'exited'/'killed'/'spawn_failed'/'rejected' — see tools.md). Missing wp-cli configuration surfaces as 'scf.not_configured'.",
1543
1899
  inputSchema: {
1544
1900
  type: z
1545
1901
  .enum(["field-group", "post-type", "taxonomy", "options-page"])
@@ -1550,23 +1906,28 @@ server.registerTool("diviops_scf_status", {
1550
1906
  .optional()
1551
1907
  .describe("List the individual pending items (key/title/type/action) instead of just counts."),
1552
1908
  },
1909
+ annotations: { idempotentHint: true },
1910
+ _meta: { idempotent: "true" },
1553
1911
  }, async ({ type, detailed }) => {
1554
- const gate = ensureWpCli();
1555
- if (!gate.ok) {
1556
- return { content: [{ type: "text", text: gate.text }] };
1557
- }
1558
- const args = ["scf", "json", "status", "--format=json"];
1559
- pushScfFlag(args, "type", type);
1560
- if (detailed)
1561
- args.push("--detailed");
1562
- const result = await wpCli.runArgs(args);
1563
- const output = result.success
1564
- ? result.output
1565
- : `Error: ${result.error}\n${result.output}`;
1566
- return { content: [{ type: "text", text: output }] };
1912
+ const response = await wrapResponse(async () => {
1913
+ const cli = ensureScfWpCli();
1914
+ const args = ["scf", "json", "status", "--format=json"];
1915
+ pushScfFlag(args, "type", type);
1916
+ if (detailed)
1917
+ args.push("--detailed");
1918
+ const result = await cli.runArgs(args);
1919
+ if (!result.success)
1920
+ failScfCommand(result, args);
1921
+ return { stdout: result.stdout, stderr: result.stderr };
1922
+ });
1923
+ return {
1924
+ content: [
1925
+ { type: "text", text: serializeEnvelope(response) },
1926
+ ],
1927
+ };
1567
1928
  });
1568
1929
  server.registerTool("diviops_scf_export", {
1569
- description: "Export SCF field groups, post types, taxonomies, and options pages as JSON — to a directory under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT) or to stdout. Wraps `wp scf json export`. Either `dir` or `stdout: true` is required. Filters can be combined; without filters, all items are exported. Note: SCF writes a fixed filename `acf-export-YYYY-MM-DD.json` inside `dir` — two exports on the same day silently overwrite. Copy/rename if you're archiving baselines.",
1930
+ description: "Export SCF field groups, post types, taxonomies, and options pages as JSON — to a directory under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT) or to stdout. Wraps `wp scf json export`. Either `dir` or `stdout: true` is required. Filters can be combined; without filters, all items are exported. Note: SCF writes a fixed filename `acf-export-YYYY-MM-DD.json` inside `dir` — two exports on the same day silently overwrite. Copy/rename if you're archiving baselines. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { stdout: string, stderr: string }. Pre-wp-cli input rejections (neither/both of `dir`/`stdout`) return code 'invalid_input' with `error.data` documenting the failed fields. wp-cli failures map to 'scf.command_failed' (same shape as scf_status). Missing wp-cli configuration surfaces as 'scf.not_configured'.",
1570
1931
  inputSchema: {
1571
1932
  dir: z
1572
1933
  .string()
@@ -1593,65 +1954,62 @@ server.registerTool("diviops_scf_export", {
1593
1954
  .optional()
1594
1955
  .describe("Comma-separated options-page def keys or admin titles. Requires ACF PRO."),
1595
1956
  },
1957
+ annotations: { idempotentHint: true },
1958
+ _meta: { idempotent: "true" },
1596
1959
  }, async ({ dir, stdout, field_groups, post_types, taxonomies, options_pages }) => {
1597
- const gate = ensureWpCli();
1598
- if (!gate.ok) {
1599
- return { content: [{ type: "text", text: gate.text }] };
1600
- }
1601
- if (!dir && !stdout) {
1602
- return {
1603
- content: [
1604
- {
1605
- type: "text",
1606
- text: "Error: pass either `dir` (absolute path under DIVIOPS_WP_CLI_SAFE_FS_ROOT) or `stdout: true`.",
1607
- },
1608
- ],
1609
- };
1610
- }
1611
- if (dir && stdout) {
1612
- return {
1613
- content: [
1614
- {
1615
- type: "text",
1616
- text: "Error: `dir` and `stdout` are mutually exclusive — pick one.",
1617
- },
1618
- ],
1619
- };
1620
- }
1621
- const args = ["scf", "json", "export"];
1622
- if (stdout)
1623
- args.push("--stdout");
1624
- pushScfFlag(args, "dir", dir);
1625
- pushScfFlag(args, "field-groups", field_groups);
1626
- pushScfFlag(args, "post-types", post_types);
1627
- pushScfFlag(args, "taxonomies", taxonomies);
1628
- pushScfFlag(args, "options-pages", options_pages);
1629
- const result = await wpCli.runArgs(args);
1630
- const output = result.success
1631
- ? result.output
1632
- : `Error: ${result.error}\n${result.output}`;
1633
- return { content: [{ type: "text", text: output }] };
1960
+ const response = await wrapResponse(async () => {
1961
+ const cli = ensureScfWpCli();
1962
+ if (!dir && !stdout) {
1963
+ withCode(ErrorCodes.INVALID_INPUT, "Pass either `dir` or `stdout`, not neither.", "Set `stdout: true` to print JSON, or `dir: '<absolute path under DIVIOPS_WP_CLI_SAFE_FS_ROOT>'` to write a file.", { missing: ["dir", "stdout"] });
1964
+ }
1965
+ if (dir && stdout) {
1966
+ withCode(ErrorCodes.INVALID_INPUT, "`dir` and `stdout` are mutually exclusive — pick one.", "Pass `dir` to write a file, OR `stdout: true` to print JSON. Not both.", { conflict: ["dir", "stdout"] });
1967
+ }
1968
+ const args = ["scf", "json", "export"];
1969
+ if (stdout)
1970
+ args.push("--stdout");
1971
+ pushScfFlag(args, "dir", dir);
1972
+ pushScfFlag(args, "field-groups", field_groups);
1973
+ pushScfFlag(args, "post-types", post_types);
1974
+ pushScfFlag(args, "taxonomies", taxonomies);
1975
+ pushScfFlag(args, "options-pages", options_pages);
1976
+ const result = await cli.runArgs(args);
1977
+ if (!result.success)
1978
+ failScfCommand(result, args);
1979
+ return { stdout: result.stdout, stderr: result.stderr };
1980
+ });
1981
+ return {
1982
+ content: [
1983
+ { type: "text", text: serializeEnvelope(response) },
1984
+ ],
1985
+ };
1634
1986
  });
1635
1987
  server.registerTool("diviops_scf_import", {
1636
- description: "Import SCF field groups, post types, taxonomies, options pages from a JSON file. Mutates the database. File path must resolve under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT). Idempotent — existing items with matching keys are updated. Wraps `wp scf json import <file>`.",
1988
+ description: "Import SCF field groups, post types, taxonomies, options pages from a JSON file. Mutates the database. File path must resolve under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT). Idempotent — existing items with matching keys are updated. Wraps `wp scf json import <file>`. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { stdout: string, stderr: string }. wp-cli failures (missing/unreadable file, malformed JSON, allowlist or FS-validator rejection) map to 'scf.command_failed' with `error.data = { exit_code, stdout, stderr, failure_kind, command }`. Missing wp-cli configuration surfaces as 'scf.not_configured'.",
1637
1989
  inputSchema: {
1638
1990
  file: z
1639
1991
  .string()
1640
1992
  .describe("Absolute path to the .json file to import. Must resolve under DIVIOPS_WP_CLI_SAFE_FS_ROOT."),
1641
1993
  },
1994
+ annotations: { idempotentHint: true },
1995
+ _meta: { idempotent: "true" },
1642
1996
  }, async ({ file }) => {
1643
- const gate = ensureWpCli();
1644
- if (!gate.ok) {
1645
- return { content: [{ type: "text", text: gate.text }] };
1646
- }
1647
- const result = await wpCli.runArgs(["scf", "json", "import", file]);
1648
- const output = result.success
1649
- ? result.output
1650
- : `Error: ${result.error}\n${result.output}`;
1651
- return { content: [{ type: "text", text: output }] };
1997
+ const response = await wrapResponse(async () => {
1998
+ const cli = ensureScfWpCli();
1999
+ const args = ["scf", "json", "import", file];
2000
+ const result = await cli.runArgs(args);
2001
+ if (!result.success)
2002
+ failScfCommand(result, args);
2003
+ return { stdout: result.stdout, stderr: result.stderr };
2004
+ });
2005
+ return {
2006
+ content: [
2007
+ { type: "text", text: serializeEnvelope(response) },
2008
+ ],
2009
+ };
1652
2010
  });
1653
2011
  server.registerTool("diviops_scf_sync", {
1654
- description: "Apply pending JSON-on-disk SCF changes to the database. Reads JSON files from the theme/plugin acf-json directory and creates/updates DB entries. Defaults to `dry_run: true` for safety — caller must opt in to mutation. Wraps `wp scf json sync`.",
2012
+ description: "Apply pending JSON-on-disk SCF changes to the database. Reads JSON files from the theme/plugin acf-json directory and creates/updates DB entries. Defaults to `dry_run: true` for safety — caller must opt in to mutation. Wraps `wp scf json sync`. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { dry_run: boolean, stdout: string, stderr: string }. NOTE: `dry_run` is passed through as wp-cli's `--dry-run` flag — the upstream output shape is wp-cli's plain-text summary, NOT the standard `data.plan = { summary, changes[] }` shape used by plugin-routed `dry_run` tools. The `dry_run` boolean is reflected in the success payload so callers can branch without re-checking input args, but the SCF-on-disk preview is what wp-cli produced. wp-cli failures map to 'scf.command_failed'; missing wp-cli configuration surfaces as 'scf.not_configured'.",
1655
2013
  inputSchema: {
1656
2014
  type: z
1657
2015
  .enum(["field-group", "post-type", "taxonomy", "options-page"])
@@ -1667,136 +2025,167 @@ server.registerTool("diviops_scf_sync", {
1667
2025
  .default(true)
1668
2026
  .describe("Preview pending changes without mutating the database. Defaults to true. Pass `false` to commit."),
1669
2027
  },
2028
+ annotations: { idempotentHint: true },
2029
+ _meta: { idempotent: "true" },
1670
2030
  }, async ({ type, key, dry_run }) => {
1671
- const gate = ensureWpCli();
1672
- if (!gate.ok) {
1673
- return { content: [{ type: "text", text: gate.text }] };
1674
- }
1675
- const args = ["scf", "json", "sync"];
1676
- pushScfFlag(args, "type", type);
1677
- pushScfFlag(args, "key", key);
1678
- if (dry_run !== false)
1679
- args.push("--dry-run");
1680
- const result = await wpCli.runArgs(args);
1681
- const output = result.success
1682
- ? result.output
1683
- : `Error: ${result.error}\n${result.output}`;
1684
- return { content: [{ type: "text", text: output }] };
2031
+ const response = await wrapResponse(async () => {
2032
+ const cli = ensureScfWpCli();
2033
+ const args = ["scf", "json", "sync"];
2034
+ pushScfFlag(args, "type", type);
2035
+ pushScfFlag(args, "key", key);
2036
+ const isDryRun = dry_run !== false;
2037
+ if (isDryRun)
2038
+ args.push("--dry-run");
2039
+ const result = await cli.runArgs(args);
2040
+ if (!result.success)
2041
+ failScfCommand(result, args);
2042
+ return {
2043
+ dry_run: isDryRun,
2044
+ stdout: result.stdout,
2045
+ stderr: result.stderr,
2046
+ };
2047
+ });
2048
+ return {
2049
+ content: [
2050
+ { type: "text", text: serializeEnvelope(response) },
2051
+ ],
2052
+ };
1685
2053
  });
1686
2054
  server.registerTool("diviops_scf_field_group_list", {
1687
- description: "List all SCF/ACF field groups in the database (post_name = ACF key, post_title, post_status, post_modified). Read-only. Queries the underlying `acf-field-group` post type via `wp post list` — works on both SCF 6.8.4+ (which dropped the legacy `wp acf field-group …` family in favor of the `wp scf json` namespace) and older ACF installs.",
2055
+ description: "List all SCF/ACF field groups in the database (post_name = ACF key, post_title, post_status, post_modified). Read-only. Queries the underlying `acf-field-group` post type via `wp post list` — works on both SCF 6.8.4+ (which dropped the legacy `wp acf field-group …` family in favor of the `wp scf json` namespace) and older ACF installs. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is `Array<{ ID, post_name, post_title, post_status, post_modified }>` parsed from wp-cli's JSON output (or an empty array on no results). wp-cli failures map to 'scf.command_failed'; missing wp-cli configuration surfaces as 'scf.not_configured'.",
2056
+ annotations: { idempotentHint: true },
2057
+ _meta: { idempotent: "true" },
1688
2058
  }, async () => {
1689
- const gate = ensureWpCli();
1690
- if (!gate.ok) {
1691
- return { content: [{ type: "text", text: gate.text }] };
1692
- }
1693
- const result = await wpCli.runArgs([
1694
- "post",
1695
- "list",
1696
- "--post_type=acf-field-group",
1697
- "--post_status=any",
1698
- "--fields=ID,post_name,post_title,post_status,post_modified",
1699
- "--format=json",
1700
- ]);
1701
- const output = result.success
1702
- ? result.output
1703
- : `Error: ${result.error}\n${result.output}`;
1704
- return { content: [{ type: "text", text: output }] };
2059
+ const response = await wrapResponse(async () => {
2060
+ const cli = ensureScfWpCli();
2061
+ const args = [
2062
+ "post",
2063
+ "list",
2064
+ "--post_type=acf-field-group",
2065
+ "--post_status=any",
2066
+ "--fields=ID,post_name,post_title,post_status,post_modified",
2067
+ "--format=json",
2068
+ ];
2069
+ const result = await cli.runArgs(args);
2070
+ if (!result.success)
2071
+ failScfCommand(result, args);
2072
+ // wp-cli emits `[]` for no rows; parse so callers get structured data.
2073
+ // Malformed JSON (shouldn't happen with --format=json on a successful
2074
+ // run, but wp-cli has surprised us before) maps to wp_error so the
2075
+ // failure is at least visible rather than silently empty.
2076
+ try {
2077
+ return JSON.parse(result.stdout || "[]");
2078
+ }
2079
+ catch (e) {
2080
+ withCode(ErrorCodes.WP_ERROR, `wp-cli returned non-JSON output for --format=json: ${e.message}`, "Inspect wp-cli's stdout for malformed output. This usually indicates a wp-cli bootstrap warning bleeding into the JSON stream — re-run with WP_CLI_DEBUG=1 in the env.");
2081
+ }
2082
+ });
2083
+ return {
2084
+ content: [
2085
+ { type: "text", text: serializeEnvelope(response) },
2086
+ ],
2087
+ };
1705
2088
  });
1706
2089
  server.registerTool("diviops_scf_field_group_get", {
1707
- description: "Fetch a single SCF/ACF field group from the `acf-field-group` post type — by ACF key (`group_abc123`, looked up via `post_name`) or by numeric WP post ID. Returns the WP post fields (post_name, post_title, post_content with serialized fields blob, post_status, post_modified). For the parsed/structured field tree including nested fields, use `diviops_scf_export --field-groups=<key> --stdout` instead. Read-only. SCF 6.8.4 dropped the legacy `wp acf field-group get` command, so this wrapper queries the post type directly via `wp post`.",
2090
+ description: "Fetch a single SCF/ACF field group from the `acf-field-group` post type — by ACF key (`group_abc123`, looked up via `post_name`) or by numeric WP post ID. Returns the WP post fields (post_name, post_title, post_content with serialized fields blob, post_status, post_modified). For the parsed/structured field tree including nested fields, use `diviops_scf_export --field-groups=<key> --stdout` instead. Read-only. SCF 6.8.4 dropped the legacy `wp acf field-group get` command, so this wrapper queries the post type directly via `wp post`. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is the parsed `wp post get --format=json` object. Unresolvable key (no row in the `acf-field-group` post type and not a numeric ID that wp-cli accepts) returns code 'not_found' with hint pointing to diviops_scf_field_group_list. wp-cli failures map to 'scf.command_failed'; missing wp-cli configuration surfaces as 'scf.not_configured'.",
1708
2091
  inputSchema: {
1709
2092
  key: z
1710
2093
  .string()
1711
2094
  .describe("ACF field-group key (`group_abc123`, matched against post_name) or numeric WP post ID."),
1712
2095
  },
2096
+ annotations: { idempotentHint: true },
2097
+ _meta: { idempotent: "true" },
1713
2098
  }, async ({ key }) => {
1714
- const gate = ensureWpCli();
1715
- if (!gate.ok) {
1716
- return { content: [{ type: "text", text: gate.text }] };
1717
- }
1718
- // If the input looks like a numeric ID, hand it to `wp post get` directly.
1719
- // Otherwise treat it as an ACF key and resolve via post_name first.
1720
- const isNumericId = /^\d+$/.test(key);
1721
- if (isNumericId) {
1722
- const result = await wpCli.runArgs([
1723
- "post",
1724
- "get",
1725
- key,
1726
- "--format=json",
1727
- ]);
1728
- const output = result.success
1729
- ? result.output
1730
- : `Error: ${result.error}\n${result.output}`;
1731
- return { content: [{ type: "text", text: output }] };
1732
- }
1733
- // Resolve ACF key → post ID via `wp post list --name=<key>`. Single-row
1734
- // lookup; returns [] if the key isn't found.
1735
- const lookup = await wpCli.runArgs([
1736
- "post",
1737
- "list",
1738
- "--post_type=acf-field-group",
1739
- "--post_status=any",
1740
- `--name=${key}`,
1741
- "--fields=ID",
1742
- "--format=json",
1743
- ]);
1744
- if (!lookup.success) {
1745
- return {
1746
- content: [
1747
- {
1748
- type: "text",
1749
- text: `Error looking up field-group key "${key}": ${lookup.error}\n${lookup.output}`,
1750
- },
1751
- ],
1752
- };
1753
- }
1754
- let postId = null;
1755
- try {
1756
- const rows = JSON.parse(lookup.output);
1757
- if (Array.isArray(rows) && rows.length > 0) {
1758
- postId = String(rows[0].ID);
2099
+ const response = await wrapResponse(async () => {
2100
+ const cli = ensureScfWpCli();
2101
+ // If the input looks like a numeric ID, hand it to `wp post get` directly.
2102
+ // Otherwise treat it as an ACF key and resolve via post_name first.
2103
+ const isNumericId = /^\d+$/.test(key);
2104
+ let postId;
2105
+ if (isNumericId) {
2106
+ postId = key;
1759
2107
  }
1760
- }
1761
- catch {
1762
- // Fall through — postId stays null, return a clear "not found" error.
1763
- }
1764
- if (!postId) {
1765
- return {
1766
- content: [
1767
- {
1768
- type: "text",
1769
- text: `No field-group found for key "${key}". Expected an ACF key (e.g. "group_5f8a1b2c3d4e5") or a numeric WP post ID (e.g. "287"). Use diviops_scf_field_group_list to see available keys (post_name field).`,
1770
- },
1771
- ],
1772
- };
1773
- }
1774
- const result = await wpCli.runArgs([
1775
- "post",
1776
- "get",
1777
- postId,
1778
- "--format=json",
1779
- ]);
1780
- const output = result.success
1781
- ? result.output
1782
- : `Error: ${result.error}\n${result.output}`;
1783
- return { content: [{ type: "text", text: output }] };
2108
+ else {
2109
+ const lookupArgs = [
2110
+ "post",
2111
+ "list",
2112
+ "--post_type=acf-field-group",
2113
+ "--post_status=any",
2114
+ `--name=${key}`,
2115
+ "--fields=ID",
2116
+ "--format=json",
2117
+ ];
2118
+ const lookup = await cli.runArgs(lookupArgs);
2119
+ if (!lookup.success)
2120
+ failScfCommand(lookup, lookupArgs);
2121
+ let resolved = null;
2122
+ try {
2123
+ const rows = JSON.parse(lookup.stdout || "[]");
2124
+ if (Array.isArray(rows) && rows.length > 0) {
2125
+ resolved = String(rows[0].ID);
2126
+ }
2127
+ }
2128
+ catch {
2129
+ // Fall through — resolved stays null, treated as not_found below.
2130
+ }
2131
+ if (!resolved) {
2132
+ withCode(ErrorCodes.NOT_FOUND, `No field-group found for key "${key}".`, 'Expected an ACF key (e.g. "group_5f8a1b2c3d4e5") or a numeric WP post ID. Run diviops_scf_field_group_list to see available field groups.', { key });
2133
+ }
2134
+ postId = resolved;
2135
+ }
2136
+ const args = ["post", "get", postId, "--format=json"];
2137
+ const result = await cli.runArgs(args);
2138
+ // For numeric IDs that don't resolve, wp-cli exits non-zero with
2139
+ // "Could not find the post with ID <n>" on stderr — surface as
2140
+ // not_found rather than the generic command_failed so callers can
2141
+ // branch uniformly on `error.code`.
2142
+ if (!result.success) {
2143
+ const stderr = result.stderr ?? "";
2144
+ if (isNumericId &&
2145
+ result.failureKind === "exited" &&
2146
+ /Could not find the post with ID/i.test(stderr)) {
2147
+ withCode(ErrorCodes.NOT_FOUND, `No field-group found for ID "${key}".`, "Run diviops_scf_field_group_list to see available field groups.", { key });
2148
+ }
2149
+ failScfCommand(result, args);
2150
+ }
2151
+ try {
2152
+ return JSON.parse(result.stdout);
2153
+ }
2154
+ catch (e) {
2155
+ withCode(ErrorCodes.WP_ERROR, `wp-cli returned non-JSON output for --format=json: ${e.message}`, "Inspect wp-cli's stdout for malformed output. Re-run with WP_CLI_DEBUG=1 in the env to surface PHP traceback.");
2156
+ }
2157
+ });
2158
+ return {
2159
+ content: [
2160
+ { type: "text", text: serializeEnvelope(response) },
2161
+ ],
2162
+ };
1784
2163
  });
1785
2164
  // ── Connection ──────────────────────────────────────────────────────
1786
2165
  server.registerTool("diviops_meta_ping", {
1787
- description: "Test the connection to the WordPress site and verify the Divi MCP plugin is active.",
2166
+ description: "Test the connection to the WordPress site and verify the Divi MCP plugin is active. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { connected: true, message: \"Connected to Divi <version>\" } and connection failure surfaces as { ok: false, error: { code: 'wp_error', message } } with the underlying transport message preserved.",
2167
+ annotations: { idempotentHint: true },
2168
+ _meta: { idempotent: "true" },
1788
2169
  }, async () => {
1789
- const result = await wp.testConnection();
2170
+ const response = await wrapResponse(async () => {
2171
+ const ping = await wp.testConnection();
2172
+ if (!ping.ok) {
2173
+ withCode(ErrorCodes.WP_ERROR, ping.message);
2174
+ }
2175
+ return { connected: true, message: ping.message };
2176
+ });
1790
2177
  return {
1791
2178
  content: [
1792
- { type: "text", text: JSON.stringify(result) },
2179
+ { type: "text", text: serializeEnvelope(response) },
1793
2180
  ],
1794
2181
  };
1795
2182
  });
1796
2183
  server.registerTool("diviops_meta_info", {
1797
- description: "Returns DiviOps MCP server identity, version, license type, and available capabilities.",
2184
+ description: "Returns DiviOps MCP server identity, version, license type, and available capabilities. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
2185
+ annotations: { idempotentHint: true },
2186
+ _meta: { idempotent: "true" },
1798
2187
  }, async () => {
1799
- const info = {
2188
+ const response = await wrapResponse(async () => ({
1800
2189
  brand: "DiviOps",
1801
2190
  server: "diviops-mcp",
1802
2191
  version: SERVER_VERSION,
@@ -1815,10 +2204,10 @@ server.registerTool("diviops_meta_info", {
1815
2204
  "preview",
1816
2205
  ],
1817
2206
  wp_cli: wpCli ? wpCli.getAllowedCommands() : false,
1818
- };
2207
+ }));
1819
2208
  return {
1820
2209
  content: [
1821
- { type: "text", text: JSON.stringify(info) },
2210
+ { type: "text", text: serializeEnvelope(response) },
1822
2211
  ],
1823
2212
  };
1824
2213
  });
@@ -1917,47 +2306,48 @@ function loadTemplates() {
1917
2306
  const templates = loadTemplates();
1918
2307
  // Register a list tool so Claude can discover available templates
1919
2308
  server.registerTool("diviops_template_list", {
1920
- description: "List available Divi page section templates. Each template contains verified block markup patterns that can be used as a base for page generation.",
2309
+ description: "List available Divi page section templates. Each template contains verified block markup patterns that can be used as a base for page generation. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is an array of { name, description, customizable, requires_css }.",
2310
+ annotations: { idempotentHint: true },
2311
+ _meta: { idempotent: "true" },
1921
2312
  }, async () => {
1922
- const list = Array.from(templates.entries()).map(([name, t]) => ({
2313
+ const response = await wrapResponse(async () => Array.from(templates.entries()).map(([name, t]) => ({
1923
2314
  name,
1924
2315
  description: t.description,
1925
2316
  customizable: t.customizable,
1926
2317
  requires_css: t.requires_css ?? false,
1927
- }));
2318
+ })));
1928
2319
  return {
1929
- content: [{ type: "text", text: JSON.stringify(list) }],
2320
+ content: [
2321
+ { type: "text", text: serializeEnvelope(response) },
2322
+ ],
1930
2323
  };
1931
2324
  });
1932
2325
  server.registerTool("diviops_template_get", {
1933
- description: "Get a specific Divi template with verified block markup, customizable variables, and usage notes. Use this to generate pages based on proven patterns.",
2326
+ description: "Get a specific Divi template with verified block markup, customizable variables, and usage notes. Use this to generate pages based on proven patterns. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }. Missing template names return ok:false with code 'not_found' and error.data.available: string[] listing the registered template names.",
1934
2327
  inputSchema: {
1935
2328
  template_name: z
1936
2329
  .string()
1937
2330
  .describe('Template name (e.g. "hero-centered", "hero-split", "hero-marquee", "features-blurbs", "cta-gradient", "cards-flex")'),
1938
2331
  },
2332
+ annotations: { idempotentHint: true },
2333
+ _meta: { idempotent: "true" },
1939
2334
  }, async ({ template_name }) => {
1940
- const template = templates.get(template_name);
1941
- if (!template) {
1942
- const available = Array.from(templates.keys()).join(", ");
1943
- return {
1944
- content: [
1945
- {
1946
- type: "text",
1947
- text: `Template "${template_name}" not found. Available: ${available}`,
1948
- },
1949
- ],
1950
- };
1951
- }
2335
+ const response = await wrapResponse(async () => {
2336
+ const template = templates.get(template_name);
2337
+ if (!template) {
2338
+ withCode(ErrorCodes.NOT_FOUND, `Template "${template_name}" not found.`, "Run diviops_template_list to see available templates.", { available: Array.from(templates.keys()) });
2339
+ }
2340
+ return template;
2341
+ });
1952
2342
  return {
1953
2343
  content: [
1954
- { type: "text", text: JSON.stringify(template) },
2344
+ { type: "text", text: serializeEnvelope(response) },
1955
2345
  ],
1956
2346
  };
1957
2347
  });
1958
2348
  // ── Variable Manager CRUD ─────────────────────────────────────────────
1959
2349
  registerPluginTool("diviops_variable_list", {
1960
- description: "List all design token variables from the Divi Variable Manager. Colors (gcid-*) come from et_global_data, numbers/strings/etc (gvid-*) from et_divi_global_variables. Filter by type or ID prefix.",
2350
+ description: "List all design token variables from the Divi Variable Manager. Colors (gcid-*) come from et_global_data, numbers/strings/etc (gvid-*) from et_divi_global_variables. Filter by type or ID prefix. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; invalid `type` returns ok:false with code 'invalid_input'.",
1961
2351
  inputSchema: {
1962
2352
  type: z
1963
2353
  .enum(["colors", "numbers", "strings", "images", "links", "fonts"])
@@ -1968,21 +2358,24 @@ registerPluginTool("diviops_variable_list", {
1968
2358
  .optional()
1969
2359
  .describe('Filter by ID prefix (e.g. "gcid-oa-" for oa design system colors)'),
1970
2360
  },
2361
+ annotations: { idempotentHint: true },
2362
+ _meta: { idempotent: "true" },
1971
2363
  }, async ({ type, prefix }) => {
1972
2364
  const params = {};
1973
2365
  if (type)
1974
2366
  params.type = type;
1975
2367
  if (prefix)
1976
2368
  params.prefix = prefix;
1977
- const result = await wp.request("/variable/list", { params });
2369
+ const result = await wp.requestEnveloped("/variable/list", { params });
1978
2370
  return {
1979
2371
  content: [
1980
- { type: "text", text: JSON.stringify(result) },
2372
+ { type: "text", text: serializeEnvelope(result) },
1981
2373
  ],
1982
2374
  };
1983
2375
  });
1984
2376
  registerPluginTool("diviops_variable_create", {
1985
- description: 'Create a design token variable in the Divi Variable Manager. Colors (type "colors") use gcid-* IDs and hex values. Numbers/strings/etc use gvid-* IDs. For type="numbers" fluid tokens, pass min+max shorthand (anchors default to 320px/1920px) or explicit targets — server generates arithmetically-correct clamp() formulas. All-px inputs emit px (safe default, root-agnostic). Rem inputs OR rem output require explicit opt-in: pass output_unit="rem" (accepts the 1rem=16px default) or root_font_size_px:N (declares your site\'s actual root font-size for correct rem emission on non-16px-root sites). Mutually exclusive with value.',
2377
+ description: 'Create a design token variable in the Divi Variable Manager. Colors (type "colors") use gcid-* IDs and hex values. Numbers/strings/etc use gvid-* IDs. For type="numbers" fluid tokens, pass min+max shorthand (anchors default to 320px/1920px) or explicit targets — server generates arithmetically-correct clamp() formulas. All-px inputs emit px (safe default, root-agnostic). Rem inputs OR rem output require explicit opt-in: pass output_unit="rem" (accepts the 1rem=16px default) or root_font_size_px:N (declares your site\'s actual root font-size for correct rem emission on non-16px-root sites). Mutually exclusive with value. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; input-shape rejections (invalid type, fluid+value conflict, rem-without-opt-in, malformed id, non-hex color, etc.) return ok:false with code \'invalid_input\' and `error.data` documenting the failed field. Algorithmic clamp() failures return code \'variable.fluid_generation_failed\' with `error.data = { min, max, targets, reason }`.' +
2378
+ DRY_RUN_DESC_SUFFIX,
1986
2379
  inputSchema: {
1987
2380
  type: z
1988
2381
  .enum(["colors", "numbers", "strings", "images", "links", "fonts"])
@@ -2022,8 +2415,11 @@ registerPluginTool("diviops_variable_create", {
2022
2415
  .positive()
2023
2416
  .optional()
2024
2417
  .describe("Site's root font-size in px (positive number), used for correct rem↔px conversion in the generated clamp() formula. Defaults to 16 (standard browser default) when omitted. Pass explicitly for sites that customize `html { font-size }` (e.g. 10 for `html { font-size: 62.5% }`, 20 for `html { font-size: 20px }`). Also counts as an opt-in signal for rem emission — passing it alone (without output_unit) implies rem output. Only applies when min/max/targets is used."),
2418
+ dry_run: DRY_RUN_FIELD,
2025
2419
  },
2026
- }, async ({ type, id, label, value, min, max, targets, output_unit, root_font_size_px, }) => {
2420
+ annotations: { idempotentHint: false },
2421
+ _meta: { idempotent: "false" },
2422
+ }, async ({ type, id, label, value, min, max, targets, output_unit, root_font_size_px, dry_run, }) => {
2027
2423
  const body = { type, label };
2028
2424
  if (value !== undefined)
2029
2425
  body.value = value;
@@ -2039,18 +2435,20 @@ registerPluginTool("diviops_variable_create", {
2039
2435
  body.output_unit = output_unit;
2040
2436
  if (root_font_size_px !== undefined)
2041
2437
  body.root_font_size_px = root_font_size_px;
2042
- const result = await wp.request("/variable/create", {
2438
+ if (dry_run)
2439
+ body.dry_run = true;
2440
+ const result = await wp.requestEnveloped("/variable/create", {
2043
2441
  method: "POST",
2044
2442
  body,
2045
2443
  });
2046
2444
  return {
2047
2445
  content: [
2048
- { type: "text", text: JSON.stringify(result) },
2446
+ { type: "text", text: serializeEnvelope(result) },
2049
2447
  ],
2050
2448
  };
2051
2449
  });
2052
2450
  registerPluginTool("diviops_variable_create_fluid_system", {
2053
- description: "Batch-emit a fluid typography + spacing + radius variable set in one call — mirrors Divi 5.4.0's Variable Generator Modal at the algorithm level (clamp() math is identical to diviops_variable_create's fluid mode) but layers profile-selectable anchors over it. Each category is independent and optional. Use for: (1) bootstrapping a design system in one call instead of 20+ individual diviops_variable_create invocations; (2) mirroring ET's variable layout so your tokens coexist with VB-generated ones in the Variable Manager; (3) deterministic preflight via dry_run before committing the registry change. By default, refuses to overwrite existing IDs (returns them in `skipped`) — pass overwrite=true to update in place. Persists in a single atomic write to the variable registry; mid-batch failures roll back cleanly.",
2451
+ description: "Batch-emit a fluid typography + spacing + radius variable set in one call — mirrors Divi 5.4.0's Variable Generator Modal at the algorithm level (clamp() math is identical to diviops_variable_create's fluid mode) but layers profile-selectable anchors over it. Each category is independent and optional. Use for: (1) bootstrapping a design system in one call instead of 20+ individual diviops_variable_create invocations; (2) mirroring ET's variable layout so your tokens coexist with VB-generated ones in the Variable Manager; (3) deterministic preflight via dry_run before committing the registry change. By default, refuses to overwrite existing IDs (returns them in `skipped`) — pass overwrite=true to update in place. Persists in a single atomic write to the variable registry; mid-batch failures roll back cleanly. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; input-shape rejections (invalid namespace, no categories, invalid profile, plan ID collision, etc.) return code 'invalid_input' with `error.data` documenting the failed field. Algorithmic scale-generation failures (degenerate ratios/anchors caught inside compute_typography_scale or compute_size_scale) return code 'variable.fluid_system_generation_failed' with `error.data = { profile, categories, reason }`.",
2054
2452
  inputSchema: {
2055
2453
  profile: z
2056
2454
  .enum(["divi-default", "wide", "custom"])
@@ -2187,6 +2585,8 @@ registerPluginTool("diviops_variable_create_fluid_system", {
2187
2585
  .default(false)
2188
2586
  .describe("When false (default), existing IDs land in `skipped` with the existing value. When true, each existing ID is updated in place (label + value rewritten, order preserved)."),
2189
2587
  },
2588
+ annotations: { idempotentHint: false },
2589
+ _meta: { idempotent: "false" },
2190
2590
  }, async ({ profile, custom_anchors, typography, spacing, radius, namespace, output_unit, root_font_size_px, dry_run, overwrite, }) => {
2191
2591
  const body = { profile };
2192
2592
  if (custom_anchors !== undefined)
@@ -2207,18 +2607,19 @@ registerPluginTool("diviops_variable_create_fluid_system", {
2207
2607
  body.dry_run = dry_run;
2208
2608
  if (overwrite !== undefined)
2209
2609
  body.overwrite = overwrite;
2210
- const result = await wp.request("/variable/create-fluid-system", {
2610
+ const result = await wp.requestEnveloped("/variable/create-fluid-system", {
2211
2611
  method: "POST",
2212
2612
  body,
2213
2613
  });
2214
2614
  return {
2215
2615
  content: [
2216
- { type: "text", text: JSON.stringify(result) },
2616
+ { type: "text", text: serializeEnvelope(result) },
2217
2617
  ],
2218
2618
  };
2219
2619
  });
2220
2620
  registerPluginTool("diviops_variable_delete", {
2221
- description: "Delete a design token variable by ID. Auto-detects storage from ID prefix (gcid-* = colors, gvid-* = numbers/strings/etc). Returns HTTP 409 when live references exist unless force=true — run diviops_variable_scan_orphans to see where the references live. Returns HTTP 403 for Divi's customizer-bound defaults (gcid-primary-color, gcid-secondary-color, gcid-heading-color, gcid-body-color, gcid-link-color); those are managed via WP Customizer theme options and can't be deleted via this tool.",
2621
+ description: "Delete a design token variable by ID. Auto-detects storage from ID prefix (gcid-* = colors, gvid-* = numbers/strings/etc). Returns the standardized envelope { ok, data?, error: { code, message, hint? } }. Live-reference collision returns ok:false with code 'conflict' (HTTP 409) and `error.data = { id: string, ref_count: number, locations: object[] }` so callers can audit before re-issuing with force=true. The `locations` array is a discriminated union by `type` content surfaces emit `{ type: 'page'|'post'|'et_header_layout'|'et_body_layout'|'et_footer_layout'|'et_pb_layout'|'et_pb_canvas', post_id: number, title: string }` (post_type as `type` so the Theme Builder + library + canvas flavors are distinguishable); preset-registry refs emit `{ type: 'preset', bucket: 'module'|'group', module: string, preset_uuid: string, preset_name: string }`. This shape is the precedent for any future conflict envelope carrying structured `error.data` collections. Run diviops_variable_scan_orphans first to see where the references live. Customizer-bound color defaults (gcid-primary-color, gcid-secondary-color, gcid-heading-color, gcid-body-color, gcid-link-color) are managed via WP Customizer theme options and reject with code 'variable.customizer_default_immutable' (HTTP 403). Missing IDs return 'not_found' (HTTP 404)." +
2622
+ DRY_RUN_DESC_SUFFIX,
2222
2623
  inputSchema: {
2223
2624
  id: z
2224
2625
  .string()
@@ -2228,30 +2629,38 @@ registerPluginTool("diviops_variable_delete", {
2228
2629
  .optional()
2229
2630
  .default(false)
2230
2631
  .describe("Delete even if live references exist. Orphans will remain in page/preset content and render as invalid CSS on the frontend — run diviops_variable_scan_orphans afterwards to audit."),
2632
+ dry_run: DRY_RUN_FIELD,
2231
2633
  },
2232
- }, async ({ id, force }) => {
2233
- const result = await wp.request("/variable/delete", {
2634
+ annotations: { idempotentHint: true },
2635
+ _meta: { idempotent: "true" },
2636
+ }, async ({ id, force, dry_run }) => {
2637
+ const body = { id, force };
2638
+ if (dry_run)
2639
+ body.dry_run = true;
2640
+ const result = await wp.requestEnveloped("/variable/delete", {
2234
2641
  method: "POST",
2235
- body: { id, force },
2642
+ body,
2236
2643
  });
2237
2644
  return {
2238
2645
  content: [
2239
- { type: "text", text: JSON.stringify(result) },
2646
+ { type: "text", text: serializeEnvelope(result) },
2240
2647
  ],
2241
2648
  };
2242
2649
  });
2243
2650
  registerPluginTool("diviops_variable_scan_orphans", {
2244
- description: "Scan pages, Theme Builder layouts (header/body/footer), Divi Library items, canvas pages, and the preset registry for gvid-/gcid- references that have no backing entry in the Variable Manager (orphans), plus variables defined but referenced nowhere (unused). Orphans render as invalid CSS on the frontend — the $variable()$ resolver falls through with no fallback. Use after a deletion with force=true, or periodically as a hygiene check. Symmetric to diviops_preset_scan_orphans.",
2651
+ description: "Scan pages, Theme Builder layouts (header/body/footer), Divi Library items, canvas pages, and the preset registry for gvid-/gcid- references that have no backing entry in the Variable Manager (orphans), plus variables defined but referenced nowhere (unused). Orphans render as invalid CSS on the frontend — the $variable()$ resolver falls through with no fallback. Use after a deletion with force=true, or periodically as a hygiene check. Symmetric to diviops_preset_scan_orphans. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }.",
2652
+ annotations: { idempotentHint: true },
2653
+ _meta: { idempotent: "true" },
2245
2654
  }, async () => {
2246
- const result = await wp.request("/variable/scan-orphans");
2655
+ const result = await wp.requestEnveloped("/variable/scan-orphans");
2247
2656
  return {
2248
2657
  content: [
2249
- { type: "text", text: JSON.stringify(result) },
2658
+ { type: "text", text: serializeEnvelope(result) },
2250
2659
  ],
2251
2660
  };
2252
2661
  });
2253
2662
  registerPluginTool("diviops_variable_used_on_page", {
2254
- description: "Detect which numeric/font variable IDs a single page actually emits — the exact set Divi 5.4.0+ uses to scope selective `:root{--gvid-*}` CSS variable emission. Walks the same content stack the frontend assembles: post_content + active Theme Builder header/body/footer template content + appended canvas content (interaction targets etc.), plus presets referenced by that content. NOTE: this is `gvid-*` only — color variables (`gcid-*`) are emitted via a separate path (`GlobalData` color block) that is NOT scoped per-page in 5.4.0; this tool returns gvid IDs only. Use for per-page orphan validation (complements global diviops_variable_scan_orphans), preflight before bulk variable rename (know which pages are affected), or to debug why a numeric/font variable doesn't render on a specific page. Read-only. Returns variable_ids (sorted, deduped), count, and the tb_template_ids resolved for that post.",
2663
+ description: "Detect which numeric/font variable IDs a single page actually emits — the exact set Divi 5.4.0+ uses to scope selective `:root{--gvid-*}` CSS variable emission. Walks the same content stack the frontend assembles: post_content + active Theme Builder header/body/footer template content + appended canvas content (interaction targets etc.), plus presets referenced by that content. NOTE: this is `gvid-*` only — color variables (`gcid-*`) are emitted via a separate path (`GlobalData` color block) that is NOT scoped per-page in 5.4.0; this tool returns gvid IDs only. Use for per-page orphan validation (complements global diviops_variable_scan_orphans), preflight before bulk variable rename (know which pages are affected), or to debug why a numeric/font variable doesn't render on a specific page. Read-only. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; success payload is { post_id, variable_ids (sorted, deduped), count, tb_template_ids }. Missing post_id returns 'not_found'; non-positive post_id returns 'invalid_input'; a Divi 5 environment without the `\\\\ET\\\\Builder\\\\FrontEnd\\\\Assets\\\\DetectFeature` class (e.g. Divi 4 active, or Divi disabled) returns 'wp_error' (HTTP 500) with a hint to activate Divi 5.",
2255
2664
  inputSchema: {
2256
2665
  post_id: z
2257
2666
  .number()
@@ -2259,16 +2668,20 @@ registerPluginTool("diviops_variable_used_on_page", {
2259
2668
  .positive()
2260
2669
  .describe("WordPress post/page ID. The page does not need to be Divi-built — TB templates and canvases attached to non-Divi posts are still scanned."),
2261
2670
  },
2671
+ annotations: { idempotentHint: true },
2672
+ _meta: { idempotent: "true" },
2262
2673
  }, async ({ post_id }) => {
2263
- const result = await wp.request(`/variable/used-on-page/${post_id}`);
2674
+ const result = await wp.requestEnveloped(`/variable/used-on-page/${post_id}`);
2264
2675
  return {
2265
2676
  content: [
2266
- { type: "text", text: JSON.stringify(result) },
2677
+ { type: "text", text: serializeEnvelope(result) },
2267
2678
  ],
2268
2679
  };
2269
2680
  });
2270
2681
  registerPluginTool("diviops_meta_flush_cache", {
2271
- description: "Flush Divi's compiled static CSS cache under wp-content/et-cache/. wp cache flush does NOT touch these files — the frontend can keep serving stale CSS after a preset/variable/module mutation until the cache is cleared. Delegates to Divi's native ET_Core_PageResource::remove_static_resources when available (response backend: \"divi_native\"), which additionally clears Theme Builder CSS scattered across other post dirs, archive/taxonomy/home/notfound CSS, the object cache, module features cache, post features cache, Google Fonts cache, dynamic assets cache, and post meta caches. Falls back to a targeted filesystem walk of numeric-named et-cache subdirs when the Divi class is absent (backend: \"fs_fallback\"). Provide exactly one selector — no site-wide default to prevent accidental full flush. Idempotent: missing cache root returns 200 with empty list.",
2682
+ description: "Flush Divi's compiled static CSS cache under wp-content/et-cache/. wp cache flush does NOT touch these files — the frontend can keep serving stale CSS after a preset/variable/module mutation until the cache is cleared. Delegates to Divi's native ET_Core_PageResource::remove_static_resources when available (response backend: \"divi_native\"), which additionally clears Theme Builder CSS scattered across other post dirs, archive/taxonomy/home/notfound CSS, the object cache, module features cache, post features cache, Google Fonts cache, dynamic assets cache, and post meta caches. Falls back to a targeted filesystem walk of numeric-named et-cache subdirs when the Divi class is absent (backend: \"fs_fallback\"). Provide exactly one selector — no site-wide default to prevent accidental full flush. Idempotent: missing cache root returns 200 with empty list. Returns the standardized envelope { ok, data?, error: { code, message, hint? } }; namespace-specific error codes: meta_flush_cache.unwritable (filesystem refused), meta_flush_cache.fs_init_failed (WP_Filesystem could not authenticate)." +
2683
+ DRY_RUN_DESC_SUFFIX +
2684
+ " Note: in `after` mode the dry-run plan reports the cutoff only — accurate file count requires the live mtime walk.",
2272
2685
  inputSchema: {
2273
2686
  post_id: z
2274
2687
  .number()
@@ -2287,8 +2700,11 @@ registerPluginTool("diviops_meta_flush_cache", {
2287
2700
  .positive()
2288
2701
  .optional()
2289
2702
  .describe("Unix timestamp — flush Divi CSS files (et-*.css) with mtime strictly greater than this value. Useful for flushing entries touched since a known deployment or mutation batch. Native backend does a single-pass filesystem sweep covering numeric post dirs AND archive/taxonomy/home/notfound/global subtrees in one walk (Visual Builder -vb-* runtime CSS preserved); fs_fallback iterates numeric post dirs whose latest file mtime > after. `flushed` lists numeric post_ids whose files were actually deleted; `skipped` lists numeric post_ids that exist but had no files pass the filter."),
2703
+ dry_run: DRY_RUN_FIELD,
2290
2704
  },
2291
- }, async ({ post_id, all, after }) => {
2705
+ annotations: { idempotentHint: true },
2706
+ _meta: { idempotent: "true" },
2707
+ }, async ({ post_id, all, after, dry_run }) => {
2292
2708
  const body = {};
2293
2709
  if (post_id !== undefined)
2294
2710
  body.post_id = post_id;
@@ -2296,13 +2712,15 @@ registerPluginTool("diviops_meta_flush_cache", {
2296
2712
  body.all = true;
2297
2713
  if (after !== undefined)
2298
2714
  body.after = after;
2299
- const result = await wp.request("/meta/flush-cache", {
2715
+ if (dry_run)
2716
+ body.dry_run = true;
2717
+ const result = await wp.requestEnveloped("/meta/flush-cache", {
2300
2718
  method: "POST",
2301
2719
  body,
2302
2720
  });
2303
2721
  return {
2304
2722
  content: [
2305
- { type: "text", text: JSON.stringify(result) },
2723
+ { type: "text", text: serializeEnvelope(result) },
2306
2724
  ],
2307
2725
  };
2308
2726
  });