@diviops/mcp-server 1.3.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,69 +201,123 @@ 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. Returns optimized schema by default (~70% smaller) with content-relevant fields only. Use raw: true for the full schema including CSS selectors and VB metadata.",
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: {
231
+ mode: z
232
+ .enum(["single", "dump_all"])
233
+ .optional()
234
+ .default("single")
235
+ .describe("'single' (default): return one module's schema. 'dump_all': return every module keyed by name plus schema_version + divi_version."),
203
236
  module_name: z
204
237
  .string()
205
- .describe('Module name, e.g. "text", "image", "accordion", or full "divi/text"'),
238
+ .optional()
239
+ .describe('Module name, e.g. "text", "image", "accordion", or full "divi/text". Required when mode="single"; ignored when mode="dump_all".'),
206
240
  raw: z
207
241
  .boolean()
208
242
  .optional()
209
243
  .default(false)
210
- .describe("Return full schema including CSS selectors and VB metadata"),
244
+ .describe("Return full schema including CSS selectors and VB metadata. Applies to mode='single' only."),
211
245
  },
212
- }, async ({ module_name, raw }) => {
213
- const result = await wp.request(`/schema/module/${encodeURIComponent(module_name)}`);
214
- const output = raw ? result : optimizeSchema(result);
246
+ annotations: { idempotentHint: true },
247
+ _meta: { idempotent: "true" },
248
+ }, async ({ mode, module_name, raw }) => {
249
+ if (mode === "dump_all") {
250
+ // Capability gate for the dump-all surface: handled here (rather
251
+ // than the wrapper's auto-derived `schema_get_module` key) so older
252
+ // plugins without /schema/module/dump-all return a typed envelope
253
+ // error instead of a 404 from the underlying request.
254
+ if (handshakeState.kind === "ok" &&
255
+ !handshakeState.capabilities["schema_get_module_dump_all"]) {
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
+ };
265
+ return {
266
+ content: [{ type: "text", text: serializeEnvelope(failure) }],
267
+ };
268
+ }
269
+ const result = await wp.requestEnveloped("/schema/module/dump-all");
270
+ return {
271
+ content: [{ type: "text", text: serializeEnvelope(result) }],
272
+ };
273
+ }
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
+ };
282
+ return {
283
+ content: [{ type: "text", text: serializeEnvelope(failure) }],
284
+ };
285
+ }
286
+ const result = await wp.requestEnveloped(`/schema/module/${encodeURIComponent(module_name)}`);
287
+ const projected = envelopeMap(result, (data) => raw ? data : optimizeSchema(data));
215
288
  return {
216
289
  content: [
217
- { type: "text", text: JSON.stringify(output) },
290
+ { type: "text", text: serializeEnvelope(projected) },
218
291
  ],
219
292
  };
220
293
  });
221
294
  registerPluginTool("diviops_schema_get_settings", {
222
- 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" },
223
298
  }, async () => {
224
- const result = await wp.request("/schema/settings");
299
+ const result = await wp.requestEnveloped("/schema/settings");
225
300
  return {
226
301
  content: [
227
- { type: "text", text: JSON.stringify(result) },
302
+ { type: "text", text: serializeEnvelope(result) },
228
303
  ],
229
304
  };
230
305
  });
231
306
  registerPluginTool("diviops_global_color_list", {
232
- 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" },
233
310
  }, async () => {
234
- const result = await wp.request("/global-color/list");
311
+ const result = await wp.requestEnveloped("/global-color/list");
235
312
  return {
236
313
  content: [
237
- { type: "text", text: JSON.stringify(result) },
314
+ { type: "text", text: serializeEnvelope(result) },
238
315
  ],
239
316
  };
240
317
  });
241
318
  registerPluginTool("diviops_global_color_create", {
242
- 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,
243
321
  inputSchema: {
244
322
  color: z
245
323
  .string()
@@ -257,8 +335,11 @@ registerPluginTool("diviops_global_color_create", {
257
335
  .optional()
258
336
  .default("active")
259
337
  .describe('Color status — "active" (default, visible in picker) or "archived" (hidden but preserved).'),
338
+ dry_run: DRY_RUN_FIELD,
260
339
  },
261
- }, async ({ color, label, folder, status }) => {
340
+ annotations: { idempotentHint: false },
341
+ _meta: { idempotent: "false" },
342
+ }, async ({ color, label, folder, status, dry_run }) => {
262
343
  const colorEntry = { color };
263
344
  if (label !== undefined)
264
345
  colorEntry.label = label;
@@ -266,14 +347,18 @@ registerPluginTool("diviops_global_color_create", {
266
347
  colorEntry.folder = folder;
267
348
  if (status)
268
349
  colorEntry.status = status;
269
- 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", {
270
354
  method: "POST",
271
- body: { colors: [colorEntry], mode: "merge" },
355
+ body,
272
356
  });
273
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
357
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
274
358
  });
275
359
  registerPluginTool("diviops_global_color_update", {
276
- 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,
277
362
  inputSchema: {
278
363
  gcid: z
279
364
  .string()
@@ -294,8 +379,11 @@ registerPluginTool("diviops_global_color_update", {
294
379
  .enum(["active", "archived"])
295
380
  .optional()
296
381
  .describe('Change status — "active" or "archived". Omit to keep existing.'),
382
+ dry_run: DRY_RUN_FIELD,
297
383
  },
298
- }, async ({ gcid, color, label, folder, status }) => {
384
+ annotations: { idempotentHint: false },
385
+ _meta: { idempotent: "conditional" },
386
+ }, async ({ gcid, color, label, folder, status, dry_run }) => {
299
387
  const colorEntry = { id: gcid };
300
388
  if (color !== undefined)
301
389
  colorEntry.color = color;
@@ -305,14 +393,18 @@ registerPluginTool("diviops_global_color_update", {
305
393
  colorEntry.folder = folder;
306
394
  if (status)
307
395
  colorEntry.status = status;
308
- 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", {
309
400
  method: "POST",
310
- body: { colors: [colorEntry], mode: "merge" },
401
+ body,
311
402
  });
312
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
403
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
313
404
  });
314
405
  registerPluginTool("diviops_global_color_delete", {
315
- 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,
316
408
  inputSchema: {
317
409
  gcid: z
318
410
  .string()
@@ -322,29 +414,36 @@ registerPluginTool("diviops_global_color_delete", {
322
414
  .optional()
323
415
  .default(false)
324
416
  .describe("If true, delete even when usedInPosts shows live references. Customizer-bound defaults remain protected regardless."),
417
+ dry_run: DRY_RUN_FIELD,
325
418
  },
326
- }, async ({ gcid, force }) => {
419
+ annotations: { idempotentHint: true },
420
+ _meta: { idempotent: "true" },
421
+ }, async ({ gcid, force, dry_run }) => {
327
422
  const body = { gcid };
328
423
  if (force)
329
424
  body.force = true;
330
- 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", {
331
428
  method: "POST",
332
429
  body,
333
430
  });
334
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
431
+ return { content: [{ type: "text", text: serializeEnvelope(result) }] };
335
432
  });
336
433
  registerPluginTool("diviops_global_font_list", {
337
- 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" },
338
437
  }, async () => {
339
- const result = await wp.request("/global-font/list");
438
+ const result = await wp.requestEnveloped("/global-font/list");
340
439
  return {
341
440
  content: [
342
- { type: "text", text: JSON.stringify(result) },
441
+ { type: "text", text: serializeEnvelope(result) },
343
442
  ],
344
443
  };
345
444
  });
346
445
  registerPluginTool("diviops_meta_find_icon", {
347
- 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? } }.",
348
447
  inputSchema: {
349
448
  query: z
350
449
  .string()
@@ -360,71 +459,85 @@ registerPluginTool("diviops_meta_find_icon", {
360
459
  .default(10)
361
460
  .describe("Max results (default 10, max 50)"),
362
461
  },
462
+ annotations: { idempotentHint: true },
463
+ _meta: { idempotent: "true" },
363
464
  }, async ({ query, type, limit }) => {
364
- 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}`);
365
466
  return {
366
467
  content: [
367
- { type: "text", text: JSON.stringify(result) },
468
+ { type: "text", text: serializeEnvelope(result) },
368
469
  ],
369
470
  };
370
471
  });
371
472
  // ── Write Tools ──────────────────────────────────────────────────────
372
473
  registerPluginTool("diviops_page_update_content", {
373
- 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,
374
476
  inputSchema: {
375
477
  page_id: z.number().describe("WordPress post/page ID to update"),
376
478
  content: z
377
479
  .string()
378
480
  .describe("Full page content in WordPress block markup format (<!-- wp:divi/section -->...<!-- /wp:divi/section -->)"),
481
+ dry_run: DRY_RUN_FIELD,
379
482
  },
380
- }, async ({ page_id, content }) => {
483
+ annotations: { idempotentHint: false },
484
+ _meta: { idempotent: "conditional" },
485
+ }, async ({ page_id, content, dry_run }) => {
381
486
  const hits = findForeignVarRefs(content, "content");
382
487
  if (hits.length > 0)
383
488
  return isolationErrorResult("diviops_page_update_content", hits);
384
- 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}`, {
385
493
  method: "POST",
386
- body: { content },
494
+ body,
387
495
  });
388
496
  return {
389
497
  content: [
390
- { type: "text", text: JSON.stringify(result) },
498
+ { type: "text", text: serializeEnvelope(result) },
391
499
  ],
392
500
  };
393
501
  });
394
502
  registerPluginTool("diviops_render_preview", {
395
- 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`).",
396
504
  inputSchema: {
397
505
  content: z.string().describe("Divi block markup to render to HTML"),
398
506
  },
507
+ annotations: { idempotentHint: true },
508
+ _meta: { idempotent: "true" },
399
509
  }, async ({ content }) => {
400
- const result = await wp.request("/render", {
510
+ const result = await wp.requestEnveloped("/render", {
401
511
  method: "POST",
402
512
  body: { content },
403
513
  });
404
514
  return {
405
515
  content: [
406
- { type: "text", text: JSON.stringify(result) },
516
+ { type: "text", text: serializeEnvelope(result) },
407
517
  ],
408
518
  };
409
519
  });
410
520
  registerPluginTool("diviops_validate_blocks", {
411
- 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).",
412
522
  inputSchema: {
413
523
  content: z.string().describe("Divi block markup to validate"),
414
524
  },
525
+ annotations: { idempotentHint: true },
526
+ _meta: { idempotent: "true" },
415
527
  }, async ({ content }) => {
416
- const result = await wp.request("/validate/blocks", {
528
+ const result = await wp.requestEnveloped("/validate/blocks", {
417
529
  method: "POST",
418
530
  body: { content },
419
531
  });
420
532
  return {
421
533
  content: [
422
- { type: "text", text: JSON.stringify(result) },
534
+ { type: "text", text: serializeEnvelope(result) },
423
535
  ],
424
536
  };
425
537
  });
426
538
  registerPluginTool("diviops_section_append", {
427
- 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,
428
541
  inputSchema: {
429
542
  page_id: z.number().describe("WordPress post/page ID"),
430
543
  content: z
@@ -435,23 +548,30 @@ registerPluginTool("diviops_section_append", {
435
548
  .optional()
436
549
  .default("end")
437
550
  .describe('Where to insert: "start" or "end" (default)'),
551
+ dry_run: DRY_RUN_FIELD,
438
552
  },
439
- }, async ({ page_id, content, position }) => {
553
+ annotations: { idempotentHint: false },
554
+ _meta: { idempotent: "false" },
555
+ }, async ({ page_id, content, position, dry_run }) => {
440
556
  const hits = findForeignVarRefs(content, "content");
441
557
  if (hits.length > 0)
442
558
  return isolationErrorResult("diviops_section_append", hits);
443
- 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}`, {
444
563
  method: "POST",
445
- body: { content, position: position ?? "end" },
564
+ body,
446
565
  });
447
566
  return {
448
567
  content: [
449
- { type: "text", text: JSON.stringify(result) },
568
+ { type: "text", text: serializeEnvelope(result) },
450
569
  ],
451
570
  };
452
571
  });
453
572
  registerPluginTool("diviops_section_replace", {
454
- 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,
455
575
  inputSchema: {
456
576
  page_id: z.number().describe("WordPress post/page ID"),
457
577
  label: z
@@ -472,8 +592,11 @@ registerPluginTool("diviops_section_replace", {
472
592
  .optional()
473
593
  .default(1)
474
594
  .describe("Which match to target (1-based, default: 1)"),
595
+ dry_run: DRY_RUN_FIELD,
475
596
  },
476
- }, 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 }) => {
477
600
  const hits = findForeignVarRefs(content, "content");
478
601
  if (hits.length > 0)
479
602
  return isolationErrorResult("diviops_section_replace", hits);
@@ -482,18 +605,21 @@ registerPluginTool("diviops_section_replace", {
482
605
  body.label = label;
483
606
  if (match_text)
484
607
  body.match_text = match_text;
485
- 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}`, {
486
611
  method: "POST",
487
612
  body,
488
613
  });
489
614
  return {
490
615
  content: [
491
- { type: "text", text: JSON.stringify(result) },
616
+ { type: "text", text: serializeEnvelope(result) },
492
617
  ],
493
618
  };
494
619
  });
495
620
  registerPluginTool("diviops_section_remove", {
496
- 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,
497
623
  inputSchema: {
498
624
  page_id: z.number().describe("WordPress post/page ID"),
499
625
  label: z
@@ -511,25 +637,30 @@ registerPluginTool("diviops_section_remove", {
511
637
  .optional()
512
638
  .default(1)
513
639
  .describe("Which match to target (1-based, default: 1)"),
640
+ dry_run: DRY_RUN_FIELD,
514
641
  },
515
- }, 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 }) => {
516
645
  const body = { occurrence };
517
646
  if (label)
518
647
  body.label = label;
519
648
  if (match_text)
520
649
  body.match_text = match_text;
521
- 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}`, {
522
653
  method: "POST",
523
654
  body,
524
655
  });
525
656
  return {
526
657
  content: [
527
- { type: "text", text: JSON.stringify(result) },
658
+ { type: "text", text: serializeEnvelope(result) },
528
659
  ],
529
660
  };
530
661
  });
531
662
  registerPluginTool("diviops_section_get", {
532
- 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\"`.",
533
664
  inputSchema: {
534
665
  page_id: z.number().describe("WordPress post/page ID"),
535
666
  label: z
@@ -548,6 +679,8 @@ registerPluginTool("diviops_section_get", {
548
679
  .default(1)
549
680
  .describe("Which match to target (1-based, default: 1)"),
550
681
  },
682
+ annotations: { idempotentHint: true },
683
+ _meta: { idempotent: "true" },
551
684
  }, async ({ page_id, label, match_text, occurrence }) => {
552
685
  const params = { occurrence: String(occurrence) };
553
686
  if (label)
@@ -555,15 +688,16 @@ registerPluginTool("diviops_section_get", {
555
688
  if (match_text)
556
689
  params.match_text = match_text;
557
690
  const qs = new URLSearchParams(params).toString();
558
- const result = await wp.request(`/section/get/${page_id}?${qs}`);
691
+ const result = await wp.requestEnveloped(`/section/get/${page_id}?${qs}`);
559
692
  return {
560
693
  content: [
561
- { type: "text", text: JSON.stringify(result) },
694
+ { type: "text", text: serializeEnvelope(result) },
562
695
  ],
563
696
  };
564
697
  });
565
698
  registerPluginTool("diviops_module_update", {
566
- 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,
567
701
  inputSchema: {
568
702
  page_id: z.number().describe("WordPress post/page ID"),
569
703
  label: z
@@ -588,8 +722,11 @@ registerPluginTool("diviops_module_update", {
588
722
  attrs: z
589
723
  .record(z.string(), z.any())
590
724
  .describe("Attribute paths (dot notation) and their new values"),
725
+ dry_run: DRY_RUN_FIELD,
591
726
  },
592
- }, 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 }) => {
593
730
  const hits = scanAttrsForForeignVarRefs(attrs);
594
731
  if (hits.length > 0)
595
732
  return isolationErrorResult("diviops_module_update", hits);
@@ -602,18 +739,21 @@ registerPluginTool("diviops_module_update", {
602
739
  body.match_text = match_text;
603
740
  if (occurrence > 1)
604
741
  body.occurrence = occurrence;
605
- 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}`, {
606
745
  method: "POST",
607
746
  body,
608
747
  });
609
748
  return {
610
749
  content: [
611
- { type: "text", text: JSON.stringify(result) },
750
+ { type: "text", text: serializeEnvelope(result) },
612
751
  ],
613
752
  };
614
753
  });
615
754
  registerPluginTool("diviops_module_move", {
616
- 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,
617
757
  inputSchema: {
618
758
  page_id: z.number().describe("WordPress post/page ID"),
619
759
  source_label: z
@@ -657,8 +797,11 @@ registerPluginTool("diviops_module_move", {
657
797
  position: z
658
798
  .enum(["before", "after"])
659
799
  .describe("Place the source before or after the target"),
800
+ dry_run: DRY_RUN_FIELD,
660
801
  },
661
- }, 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, }) => {
662
805
  const body = { position };
663
806
  if (source_label)
664
807
  body.source_label = source_label;
@@ -676,26 +819,32 @@ registerPluginTool("diviops_module_move", {
676
819
  body.target_auto_index = target_auto_index;
677
820
  if (target_occurrence > 1)
678
821
  body.target_occurrence = target_occurrence;
679
- 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}`, {
680
825
  method: "POST",
681
826
  body,
682
827
  });
683
828
  return {
684
829
  content: [
685
- { type: "text", text: JSON.stringify(result) },
830
+ { type: "text", text: serializeEnvelope(result) },
686
831
  ],
687
832
  };
688
833
  });
689
834
  registerPluginTool("diviops_module_lock", {
690
- 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,
691
837
  inputSchema: {
692
838
  page_id: z.number().describe("WordPress post/page ID"),
693
839
  label: z.string().optional().describe("Admin label of the module to lock (exact match)"),
694
840
  match_text: z.string().optional().describe("Text to search for in module markup (case-insensitive)"),
695
841
  auto_index: z.string().optional().describe('Auto-index in "type:N" format (e.g. "text:3")'),
696
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,
697
844
  },
698
- }, 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 }) => {
699
848
  const body = {};
700
849
  if (label)
701
850
  body.label = label;
@@ -705,19 +854,25 @@ registerPluginTool("diviops_module_lock", {
705
854
  body.auto_index = auto_index;
706
855
  if (occurrence && occurrence > 1)
707
856
  body.occurrence = occurrence;
708
- const result = await wp.request(`/module/lock/${page_id}`, { method: "POST", body });
709
- 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) }] };
710
861
  });
711
862
  registerPluginTool("diviops_module_unlock", {
712
- 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,
713
865
  inputSchema: {
714
866
  page_id: z.number().describe("WordPress post/page ID"),
715
867
  label: z.string().optional().describe("Admin label of the module to unlock (exact match)"),
716
868
  match_text: z.string().optional().describe("Text to search for in module markup (case-insensitive)"),
717
869
  auto_index: z.string().optional().describe('Auto-index in "type:N" format'),
718
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,
719
872
  },
720
- }, 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 }) => {
721
876
  const body = {};
722
877
  if (label)
723
878
  body.label = label;
@@ -727,11 +882,14 @@ registerPluginTool("diviops_module_unlock", {
727
882
  body.auto_index = auto_index;
728
883
  if (occurrence && occurrence > 1)
729
884
  body.occurrence = occurrence;
730
- const result = await wp.request(`/module/unlock/${page_id}`, { method: "POST", body });
731
- 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) }] };
732
889
  });
733
890
  registerPluginTool("diviops_module_clone", {
734
- 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,
735
893
  inputSchema: {
736
894
  page_id: z.number().describe("WordPress post/page ID"),
737
895
  label: z.string().optional().describe("Admin label of the module to clone (exact match)"),
@@ -739,8 +897,11 @@ registerPluginTool("diviops_module_clone", {
739
897
  auto_index: z.string().optional().describe('Auto-index in "type:N" format'),
740
898
  occurrence: z.number().int().min(1).optional().default(1).describe("Which occurrence when multiple modules share the same label (1-based)"),
741
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,
742
901
  },
743
- }, 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 }) => {
744
905
  const body = {};
745
906
  if (label)
746
907
  body.label = label;
@@ -752,11 +913,14 @@ registerPluginTool("diviops_module_clone", {
752
913
  body.occurrence = occurrence;
753
914
  if (position)
754
915
  body.position = position;
755
- const result = await wp.request(`/module/clone/${page_id}`, { method: "POST", body });
756
- 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) }] };
757
920
  });
758
921
  registerPluginTool("diviops_page_create", {
759
- 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,
760
924
  inputSchema: {
761
925
  title: z.string().describe("Page title"),
762
926
  content: z
@@ -769,25 +933,31 @@ registerPluginTool("diviops_page_create", {
769
933
  .optional()
770
934
  .default("draft")
771
935
  .describe("Post status"),
936
+ dry_run: DRY_RUN_FIELD,
772
937
  },
773
- }, async ({ title, content, status }) => {
938
+ annotations: { idempotentHint: false },
939
+ _meta: { idempotent: "false" },
940
+ }, async ({ title, content, status, dry_run }) => {
774
941
  if (content) {
775
942
  const hits = findForeignVarRefs(content, "content");
776
943
  if (hits.length > 0)
777
944
  return isolationErrorResult("diviops_page_create", hits);
778
945
  }
779
- 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", {
780
950
  method: "POST",
781
- body: { title, content: content ?? "", status: status ?? "draft" },
951
+ body,
782
952
  });
783
953
  return {
784
954
  content: [
785
- { type: "text", text: JSON.stringify(result) },
955
+ { type: "text", text: serializeEnvelope(result) },
786
956
  ],
787
957
  };
788
958
  });
789
959
  registerPluginTool("diviops_page_trash", {
790
- 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.",
791
961
  inputSchema: {
792
962
  post_id: z.number().int().describe("WordPress post/page ID"),
793
963
  force: z
@@ -801,8 +971,10 @@ registerPluginTool("diviops_page_trash", {
801
971
  .default(false)
802
972
  .describe("When true, return the change plan without mutating state."),
803
973
  },
974
+ annotations: { idempotentHint: true },
975
+ _meta: { idempotent: "true" },
804
976
  }, async ({ post_id, force, dry_run }) => {
805
- const result = await wp.request(`/page/trash/${post_id}`, {
977
+ const result = await wp.requestEnveloped(`/page/trash/${post_id}`, {
806
978
  method: "POST",
807
979
  body: {
808
980
  force: force ?? false,
@@ -811,12 +983,12 @@ registerPluginTool("diviops_page_trash", {
811
983
  });
812
984
  return {
813
985
  content: [
814
- { type: "text", text: JSON.stringify(result) },
986
+ { type: "text", text: serializeEnvelope(result) },
815
987
  ],
816
988
  };
817
989
  });
818
990
  registerPluginTool("diviops_page_update_status", {
819
- 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.",
820
992
  inputSchema: {
821
993
  post_id: z.number().int().describe("WordPress post/page ID"),
822
994
  status: z
@@ -832,6 +1004,8 @@ registerPluginTool("diviops_page_update_status", {
832
1004
  .default(false)
833
1005
  .describe("When true, return the change plan without mutating state."),
834
1006
  },
1007
+ annotations: { idempotentHint: true },
1008
+ _meta: { idempotent: "true" },
835
1009
  }, async ({ post_id, status, date_gmt, dry_run }) => {
836
1010
  const body = {
837
1011
  status,
@@ -839,29 +1013,31 @@ registerPluginTool("diviops_page_update_status", {
839
1013
  };
840
1014
  if (date_gmt)
841
1015
  body.date_gmt = date_gmt;
842
- const result = await wp.request(`/page/update-status/${post_id}`, {
1016
+ const result = await wp.requestEnveloped(`/page/update-status/${post_id}`, {
843
1017
  method: "POST",
844
1018
  body,
845
1019
  });
846
1020
  return {
847
1021
  content: [
848
- { type: "text", text: JSON.stringify(result) },
1022
+ { type: "text", text: serializeEnvelope(result) },
849
1023
  ],
850
1024
  };
851
1025
  });
852
1026
  // ── Preset Tools ────────────────────────────────────────────────────
853
1027
  registerPluginTool("diviops_preset_audit", {
854
- 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" },
855
1031
  }, async () => {
856
- const result = await wp.request("/preset/audit");
1032
+ const result = await wp.requestEnveloped("/preset/audit");
857
1033
  return {
858
1034
  content: [
859
- { type: "text", text: JSON.stringify(result) },
1035
+ { type: "text", text: serializeEnvelope(result) },
860
1036
  ],
861
1037
  };
862
1038
  });
863
1039
  registerPluginTool("diviops_preset_cleanup", {
864
- 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.',
865
1041
  inputSchema: {
866
1042
  dry_run: z
867
1043
  .boolean()
@@ -886,6 +1062,8 @@ registerPluginTool("diviops_preset_cleanup", {
886
1062
  .default("spam")
887
1063
  .describe('Scope for remove_orphans: "spam" (only spam-named orphans) or "all" (all non-default orphans).'),
888
1064
  },
1065
+ annotations: { idempotentHint: false },
1066
+ _meta: { idempotent: "false" },
889
1067
  }, async ({ dry_run, dedup, action, prefix, scope }) => {
890
1068
  const body = { dry_run: dry_run ?? true };
891
1069
  if (dedup)
@@ -896,18 +1074,19 @@ registerPluginTool("diviops_preset_cleanup", {
896
1074
  body.prefix = prefix;
897
1075
  if (action === "remove_orphans" && scope)
898
1076
  body.scope = scope;
899
- const result = await wp.request("/preset/cleanup", {
1077
+ const result = await wp.requestEnveloped("/preset/cleanup", {
900
1078
  method: "POST",
901
1079
  body,
902
1080
  });
903
1081
  return {
904
1082
  content: [
905
- { type: "text", text: JSON.stringify(result) },
1083
+ { type: "text", text: serializeEnvelope(result) },
906
1084
  ],
907
1085
  };
908
1086
  });
909
1087
  registerPluginTool("diviops_preset_update", {
910
- 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,
911
1090
  inputSchema: {
912
1091
  preset_id: z.string().describe("Preset ID (UUID or short ID)"),
913
1092
  name: z.string().optional().describe("New display name for the preset"),
@@ -920,8 +1099,11 @@ registerPluginTool("diviops_preset_update", {
920
1099
  .int()
921
1100
  .optional()
922
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,
923
1103
  },
924
- }, async ({ preset_id, name, attrs, priority }) => {
1104
+ annotations: { idempotentHint: false },
1105
+ _meta: { idempotent: "conditional" },
1106
+ }, async ({ preset_id, name, attrs, priority, dry_run }) => {
925
1107
  const body = { preset_id };
926
1108
  if (name)
927
1109
  body.name = name;
@@ -929,18 +1111,20 @@ registerPluginTool("diviops_preset_update", {
929
1111
  body.attrs = attrs;
930
1112
  if (typeof priority === "number")
931
1113
  body.priority = priority;
932
- const result = await wp.request("/preset/update", {
1114
+ if (dry_run)
1115
+ body.dry_run = true;
1116
+ const result = await wp.requestEnveloped("/preset/update", {
933
1117
  method: "POST",
934
1118
  body,
935
1119
  });
936
1120
  return {
937
1121
  content: [
938
- { type: "text", text: JSON.stringify(result) },
1122
+ { type: "text", text: serializeEnvelope(result) },
939
1123
  ],
940
1124
  };
941
1125
  });
942
1126
  registerPluginTool("diviops_preset_delete", {
943
- 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.",
944
1128
  inputSchema: {
945
1129
  preset_id: z.string().describe("Preset ID to delete"),
946
1130
  force: z
@@ -948,22 +1132,26 @@ registerPluginTool("diviops_preset_delete", {
948
1132
  .optional()
949
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)."),
950
1134
  },
1135
+ annotations: { idempotentHint: true },
1136
+ _meta: { idempotent: "true" },
951
1137
  }, async ({ preset_id, force }) => {
952
1138
  const body = { preset_id };
953
1139
  if (force !== undefined)
954
1140
  body.force = force;
955
- const result = await wp.request("/preset/delete", {
1141
+ const result = await wp.requestEnveloped("/preset/delete", {
956
1142
  method: "POST",
957
1143
  body,
958
1144
  });
959
1145
  return {
960
1146
  content: [
961
- { type: "text", text: JSON.stringify(result) },
1147
+ { type: "text", text: serializeEnvelope(result) },
962
1148
  ],
963
1149
  };
964
1150
  });
965
1151
  registerPluginTool("diviops_preset_create", {
966
- 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.",
967
1155
  inputSchema: {
968
1156
  module_name: z
969
1157
  .string()
@@ -998,8 +1186,11 @@ registerPluginTool("diviops_preset_create", {
998
1186
  .int()
999
1187
  .optional()
1000
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,
1001
1190
  },
1002
- }, 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 }) => {
1003
1194
  if (type === "group" && (!group_name || !group_id)) {
1004
1195
  throw new Error('type="group" requires both group_name and group_id. Example: group_name="divi/font", group_id="designTitleText".');
1005
1196
  }
@@ -1014,15 +1205,17 @@ registerPluginTool("diviops_preset_create", {
1014
1205
  body.make_default = true;
1015
1206
  if (typeof priority === "number")
1016
1207
  body.priority = priority;
1017
- 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 });
1018
1211
  return {
1019
1212
  content: [
1020
- { type: "text", text: JSON.stringify(result) },
1213
+ { type: "text", text: serializeEnvelope(result) },
1021
1214
  ],
1022
1215
  };
1023
1216
  });
1024
1217
  registerPluginTool("diviops_preset_reassign", {
1025
- 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 }`.',
1026
1219
  inputSchema: {
1027
1220
  old_uuid: z
1028
1221
  .string()
@@ -1050,6 +1243,8 @@ registerPluginTool("diviops_preset_reassign", {
1050
1243
  .default("both")
1051
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.'),
1052
1245
  },
1246
+ annotations: { idempotentHint: true },
1247
+ _meta: { idempotent: "true" },
1053
1248
  }, async ({ old_uuid, new_uuid, page_ids, mode, strip_inline, scope }) => {
1054
1249
  const body = {
1055
1250
  old_uuid,
@@ -1060,28 +1255,31 @@ registerPluginTool("diviops_preset_reassign", {
1060
1255
  };
1061
1256
  if (page_ids)
1062
1257
  body.page_ids = page_ids;
1063
- const result = await wp.request("/preset/reassign", {
1258
+ const result = await wp.requestEnveloped("/preset/reassign", {
1064
1259
  method: "POST",
1065
1260
  body,
1066
1261
  });
1067
1262
  return {
1068
1263
  content: [
1069
- { type: "text", text: JSON.stringify(result) },
1264
+ { type: "text", text: serializeEnvelope(result) },
1070
1265
  ],
1071
1266
  };
1072
1267
  });
1073
1268
  registerPluginTool("diviops_preset_scan_orphans", {
1074
- 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" },
1075
1272
  }, async () => {
1076
- const result = await wp.request("/preset/scan-orphans");
1273
+ const result = await wp.requestEnveloped("/preset/scan-orphans");
1077
1274
  return {
1078
1275
  content: [
1079
- { type: "text", text: JSON.stringify(result) },
1276
+ { type: "text", text: serializeEnvelope(result) },
1080
1277
  ],
1081
1278
  };
1082
1279
  });
1083
1280
  registerPluginTool("diviops_preset_set_default", {
1084
- 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,
1085
1283
  inputSchema: {
1086
1284
  preset_id: z
1087
1285
  .string()
@@ -1099,8 +1297,11 @@ registerPluginTool("diviops_preset_set_default", {
1099
1297
  .boolean()
1100
1298
  .optional()
1101
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,
1102
1301
  },
1103
- }, async ({ preset_id, type, module, unset }) => {
1302
+ annotations: { idempotentHint: true },
1303
+ _meta: { idempotent: "true" },
1304
+ }, async ({ preset_id, type, module, unset, dry_run }) => {
1104
1305
  const body = {};
1105
1306
  if (preset_id !== undefined)
1106
1307
  body.preset_id = preset_id;
@@ -1110,19 +1311,21 @@ registerPluginTool("diviops_preset_set_default", {
1110
1311
  body.module = module;
1111
1312
  if (unset)
1112
1313
  body.unset = true;
1113
- 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", {
1114
1317
  method: "POST",
1115
1318
  body,
1116
1319
  });
1117
1320
  return {
1118
1321
  content: [
1119
- { type: "text", text: JSON.stringify(result) },
1322
+ { type: "text", text: serializeEnvelope(result) },
1120
1323
  ],
1121
1324
  };
1122
1325
  });
1123
1326
  // ── Library Tools ───────────────────────────────────────────────────
1124
1327
  registerPluginTool("diviops_library_list", {
1125
- 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? } }.",
1126
1329
  inputSchema: {
1127
1330
  layout_type: z
1128
1331
  .string()
@@ -1138,6 +1341,8 @@ registerPluginTool("diviops_library_list", {
1138
1341
  .default(50)
1139
1342
  .describe("Max results (default 50)"),
1140
1343
  },
1344
+ annotations: { idempotentHint: true },
1345
+ _meta: { idempotent: "true" },
1141
1346
  }, async ({ layout_type, scope, per_page }) => {
1142
1347
  const params = {};
1143
1348
  if (layout_type)
@@ -1146,28 +1351,31 @@ registerPluginTool("diviops_library_list", {
1146
1351
  params.scope = scope;
1147
1352
  if (per_page)
1148
1353
  params.per_page = String(per_page);
1149
- const result = await wp.request("/library/items", { params });
1354
+ const result = await wp.requestEnveloped("/library/items", { params });
1150
1355
  return {
1151
1356
  content: [
1152
- { type: "text", text: JSON.stringify(result) },
1357
+ { type: "text", text: serializeEnvelope(result) },
1153
1358
  ],
1154
1359
  };
1155
1360
  });
1156
1361
  registerPluginTool("diviops_library_get", {
1157
- 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.",
1158
1363
  inputSchema: {
1159
1364
  item_id: z.number().describe("Library item ID"),
1160
1365
  },
1366
+ annotations: { idempotentHint: true },
1367
+ _meta: { idempotent: "true" },
1161
1368
  }, async ({ item_id }) => {
1162
- const result = await wp.request(`/library/item/${item_id}`);
1369
+ const result = await wp.requestEnveloped(`/library/item/${item_id}`);
1163
1370
  return {
1164
1371
  content: [
1165
- { type: "text", text: JSON.stringify(result) },
1372
+ { type: "text", text: serializeEnvelope(result) },
1166
1373
  ],
1167
1374
  };
1168
1375
  });
1169
1376
  registerPluginTool("diviops_library_save", {
1170
- 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,
1171
1379
  inputSchema: {
1172
1380
  title: z.string().describe("Display name for the library item"),
1173
1381
  content: z
@@ -1183,26 +1391,27 @@ registerPluginTool("diviops_library_save", {
1183
1391
  .optional()
1184
1392
  .default("non_global")
1185
1393
  .describe('"global" = synced across all uses, "non_global" = independent copies'),
1394
+ dry_run: DRY_RUN_FIELD,
1186
1395
  },
1187
- }, async ({ title, content, layout_type, scope }) => {
1188
- 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", {
1189
1403
  method: "POST",
1190
- body: {
1191
- title,
1192
- content,
1193
- layout_type,
1194
- scope,
1195
- },
1404
+ body,
1196
1405
  });
1197
1406
  return {
1198
1407
  content: [
1199
- { type: "text", text: JSON.stringify(result) },
1408
+ { type: "text", text: serializeEnvelope(result) },
1200
1409
  ],
1201
1410
  };
1202
1411
  });
1203
1412
  // ── Theme Builder Tools ─────────────────────────────────────────────
1204
1413
  registerPluginTool("diviops_tb_template_list", {
1205
- 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? } }.",
1206
1415
  inputSchema: {
1207
1416
  per_page: z
1208
1417
  .number()
@@ -1212,53 +1421,65 @@ registerPluginTool("diviops_tb_template_list", {
1212
1421
  .describe("Results per page (max 100)"),
1213
1422
  page: z.number().optional().default(1).describe("Page number"),
1214
1423
  },
1424
+ annotations: { idempotentHint: true },
1425
+ _meta: { idempotent: "true" },
1215
1426
  }, async ({ per_page, page }) => {
1216
1427
  const params = {};
1217
1428
  if (per_page)
1218
1429
  params.per_page = String(per_page);
1219
1430
  if (page)
1220
1431
  params.page = String(page);
1221
- const result = await wp.request("/theme-builder/template/list", { params });
1432
+ const result = await wp.requestEnveloped("/theme-builder/template/list", { params });
1222
1433
  return {
1223
1434
  content: [
1224
- { type: "text", text: JSON.stringify(result) },
1435
+ { type: "text", text: serializeEnvelope(result) },
1225
1436
  ],
1226
1437
  };
1227
1438
  });
1228
1439
  registerPluginTool("diviops_tb_layout_get", {
1229
- 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.",
1230
1441
  inputSchema: {
1231
1442
  layout_id: z
1232
1443
  .number()
1233
1444
  .describe("Layout post ID (from template header_layout_id, body_layout_id, or footer_layout_id)"),
1234
1445
  },
1446
+ annotations: { idempotentHint: true },
1447
+ _meta: { idempotent: "true" },
1235
1448
  }, async ({ layout_id }) => {
1236
- const result = await wp.request(`/theme-builder/layout/get/${layout_id}`);
1449
+ const result = await wp.requestEnveloped(`/theme-builder/layout/get/${layout_id}`);
1237
1450
  return {
1238
1451
  content: [
1239
- { type: "text", text: JSON.stringify(result) },
1452
+ { type: "text", text: serializeEnvelope(result) },
1240
1453
  ],
1241
1454
  };
1242
1455
  });
1243
1456
  registerPluginTool("diviops_tb_layout_update", {
1244
- 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,
1245
1459
  inputSchema: {
1246
1460
  layout_id: z.number().describe("Layout post ID to update"),
1247
1461
  content: z.string().describe("New block markup content"),
1462
+ dry_run: DRY_RUN_FIELD,
1248
1463
  },
1249
- }, async ({ layout_id, content }) => {
1250
- 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}`, {
1251
1471
  method: "PUT",
1252
- body: { content },
1472
+ body,
1253
1473
  });
1254
1474
  return {
1255
1475
  content: [
1256
- { type: "text", text: JSON.stringify(result) },
1476
+ { type: "text", text: serializeEnvelope(result) },
1257
1477
  ],
1258
1478
  };
1259
1479
  });
1260
1480
  registerPluginTool("diviops_tb_template_create", {
1261
- 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,
1262
1483
  inputSchema: {
1263
1484
  title: z.string().describe('Template name (e.g. "Landing Pages")'),
1264
1485
  condition: z
@@ -1274,21 +1495,28 @@ registerPluginTool("diviops_tb_template_create", {
1274
1495
  .optional()
1275
1496
  .default("")
1276
1497
  .describe("Footer block markup (empty = inherit from default template)"),
1498
+ dry_run: DRY_RUN_FIELD,
1277
1499
  },
1278
- }, async ({ title, condition, header_content, footer_content }) => {
1279
- 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", {
1280
1507
  method: "POST",
1281
- body: { title, condition, header_content, footer_content },
1508
+ body,
1282
1509
  });
1283
1510
  return {
1284
1511
  content: [
1285
- { type: "text", text: JSON.stringify(result) },
1512
+ { type: "text", text: serializeEnvelope(result) },
1286
1513
  ],
1287
1514
  };
1288
1515
  });
1289
1516
  // ── Canvas Tools ────────────────────────────────────────────────────
1290
1517
  registerPluginTool("diviops_canvas_create", {
1291
- 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,
1292
1520
  inputSchema: {
1293
1521
  title: z
1294
1522
  .string()
@@ -1311,8 +1539,11 @@ registerPluginTool("diviops_canvas_create", {
1311
1539
  .number()
1312
1540
  .optional()
1313
1541
  .describe("Layering order (higher = on top)"),
1542
+ dry_run: DRY_RUN_FIELD,
1314
1543
  },
1315
- }, 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, }) => {
1316
1547
  const body = {
1317
1548
  title,
1318
1549
  parent_page_id,
@@ -1324,15 +1555,17 @@ registerPluginTool("diviops_canvas_create", {
1324
1555
  body.append_to_main = append_to_main;
1325
1556
  if (z_index !== undefined)
1326
1557
  body.z_index = z_index;
1327
- 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 });
1328
1561
  return {
1329
1562
  content: [
1330
- { type: "text", text: JSON.stringify(result) },
1563
+ { type: "text", text: serializeEnvelope(result) },
1331
1564
  ],
1332
1565
  };
1333
1566
  });
1334
1567
  registerPluginTool("diviops_canvas_list", {
1335
- 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? } }.",
1336
1569
  inputSchema: {
1337
1570
  parent_page_id: z
1338
1571
  .number()
@@ -1347,36 +1580,41 @@ registerPluginTool("diviops_canvas_list", {
1347
1580
  .default(50)
1348
1581
  .describe("Max results (default 50, 1-100)"),
1349
1582
  },
1583
+ annotations: { idempotentHint: true },
1584
+ _meta: { idempotent: "true" },
1350
1585
  }, async ({ parent_page_id, per_page }) => {
1351
1586
  const params = {};
1352
1587
  if (parent_page_id)
1353
1588
  params.parent_page_id = String(parent_page_id);
1354
1589
  if (per_page)
1355
1590
  params.per_page = String(per_page);
1356
- const result = await wp.request("/canvas/list", { params });
1591
+ const result = await wp.requestEnveloped("/canvas/list", { params });
1357
1592
  return {
1358
1593
  content: [
1359
- { type: "text", text: JSON.stringify(result) },
1594
+ { type: "text", text: serializeEnvelope(result) },
1360
1595
  ],
1361
1596
  };
1362
1597
  });
1363
1598
  registerPluginTool("diviops_canvas_get", {
1364
- 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.",
1365
1600
  inputSchema: {
1366
1601
  canvas_post_id: z
1367
1602
  .number()
1368
1603
  .describe("Canvas post ID (from diviops_canvas_list)"),
1369
1604
  },
1605
+ annotations: { idempotentHint: true },
1606
+ _meta: { idempotent: "true" },
1370
1607
  }, async ({ canvas_post_id }) => {
1371
- const result = await wp.request(`/canvas/get/${canvas_post_id}`);
1608
+ const result = await wp.requestEnveloped(`/canvas/get/${canvas_post_id}`);
1372
1609
  return {
1373
1610
  content: [
1374
- { type: "text", text: JSON.stringify(result) },
1611
+ { type: "text", text: serializeEnvelope(result) },
1375
1612
  ],
1376
1613
  };
1377
1614
  });
1378
1615
  registerPluginTool("diviops_canvas_update", {
1379
- 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,
1380
1618
  inputSchema: {
1381
1619
  canvas_post_id: z.number().describe("Canvas post ID"),
1382
1620
  content: z
@@ -1389,8 +1627,11 @@ registerPluginTool("diviops_canvas_update", {
1389
1627
  .optional()
1390
1628
  .describe('Append position: "above", "below", or "" to clear'),
1391
1629
  z_index: z.number().optional().describe("Layering order"),
1630
+ dry_run: DRY_RUN_FIELD,
1392
1631
  },
1393
- }, 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 }) => {
1394
1635
  const body = {};
1395
1636
  if (content !== undefined)
1396
1637
  body.content = content;
@@ -1400,18 +1641,20 @@ registerPluginTool("diviops_canvas_update", {
1400
1641
  body.append_to_main = append_to_main;
1401
1642
  if (z_index !== undefined)
1402
1643
  body.z_index = z_index;
1403
- 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}`, {
1404
1647
  method: "POST",
1405
1648
  body,
1406
1649
  });
1407
1650
  return {
1408
1651
  content: [
1409
- { type: "text", text: JSON.stringify(result) },
1652
+ { type: "text", text: serializeEnvelope(result) },
1410
1653
  ],
1411
1654
  };
1412
1655
  });
1413
1656
  registerPluginTool("diviops_canvas_duplicate", {
1414
- 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'.",
1415
1658
  inputSchema: {
1416
1659
  canvas_post_id: z.number().describe("Source canvas post ID"),
1417
1660
  title: z
@@ -1424,59 +1667,145 @@ registerPluginTool("diviops_canvas_duplicate", {
1424
1667
  .default(false)
1425
1668
  .describe("When true, return the change plan without creating the canvas."),
1426
1669
  },
1670
+ annotations: { idempotentHint: false },
1671
+ _meta: { idempotent: "conditional" },
1427
1672
  }, async ({ canvas_post_id, title, dry_run }) => {
1428
1673
  const body = { dry_run: dry_run ?? false };
1429
1674
  if (title !== undefined)
1430
1675
  body.title = title;
1431
- const result = await wp.request(`/canvas/duplicate/${canvas_post_id}`, {
1676
+ const result = await wp.requestEnveloped(`/canvas/duplicate/${canvas_post_id}`, {
1432
1677
  method: "POST",
1433
1678
  body,
1434
1679
  });
1435
1680
  return {
1436
1681
  content: [
1437
- { type: "text", text: JSON.stringify(result) },
1682
+ { type: "text", text: serializeEnvelope(result) },
1438
1683
  ],
1439
1684
  };
1440
1685
  });
1441
1686
  registerPluginTool("diviops_canvas_delete", {
1442
- 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,
1443
1689
  inputSchema: {
1444
1690
  canvas_post_id: z.number().describe("Canvas post ID to delete"),
1691
+ dry_run: DRY_RUN_FIELD,
1445
1692
  },
1446
- }, async ({ canvas_post_id }) => {
1447
- 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}`, {
1448
1700
  method: "POST",
1701
+ body,
1449
1702
  });
1450
1703
  return {
1451
1704
  content: [
1452
- { type: "text", text: JSON.stringify(result) },
1705
+ { type: "text", text: serializeEnvelope(result) },
1453
1706
  ],
1454
1707
  };
1455
1708
  });
1456
1709
  // ── WP-CLI ──────────────────────────────────────────────────────────
1457
1710
  server.registerTool("diviops_meta_wp_cli", {
1458
- 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.",
1459
1712
  inputSchema: {
1460
1713
  command: z
1461
1714
  .string()
1462
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"'),
1463
1716
  },
1717
+ annotations: { idempotentHint: false },
1718
+ _meta: { idempotent: "conditional" },
1464
1719
  }, async ({ command }) => {
1465
- 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
+ }
1466
1798
  return {
1467
- content: [
1468
- {
1469
- type: "text",
1470
- 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.',
1471
- },
1472
- ],
1799
+ stdout: result.stdout,
1800
+ stderr: result.stderr,
1801
+ exit_code: 0,
1473
1802
  };
1474
- }
1475
- const result = await wpCli.run(command);
1476
- const output = result.success
1477
- ? result.output
1478
- : `Error: ${result.error}\n${result.output}`;
1479
- return { content: [{ type: "text", text: output }] };
1803
+ });
1804
+ return {
1805
+ content: [
1806
+ { type: "text", text: serializeEnvelope(response) },
1807
+ ],
1808
+ };
1480
1809
  });
1481
1810
  // ── SCF (Secure Custom Fields / ACF) wrappers ───────────────────────
1482
1811
  //
@@ -1484,15 +1813,25 @@ server.registerTool("diviops_meta_wp_cli", {
1484
1813
  // CLI family (also reachable as `wp acf json …`). The plugin file at
1485
1814
  // wp-content/plugins/secure-custom-fields/src/CLI/JsonCommand.php is the
1486
1815
  // upstream source of truth for flag shapes — keep these wrappers aligned.
1487
- 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() {
1488
1831
  if (!wpCli) {
1489
- return {
1490
- ok: false,
1491
- text: "WP-CLI not configured. Set WP_PATH (Local by Flywheel auto-detect) " +
1492
- "or WP_CLI_CMD (containerized wrappers) to enable SCF round-trip tools.",
1493
- };
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.");
1494
1833
  }
1495
- return { ok: true };
1834
+ return wpCli;
1496
1835
  }
1497
1836
  function pushScfFlag(args, name, value) {
1498
1837
  if (!value)
@@ -1503,8 +1842,60 @@ function pushScfFlag(args, name, value) {
1503
1842
  // spaces flow through verbatim.
1504
1843
  args.push(`--${name}=${value}`);
1505
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
+ }
1506
1897
  server.registerTool("diviops_scf_status", {
1507
- 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'.",
1508
1899
  inputSchema: {
1509
1900
  type: z
1510
1901
  .enum(["field-group", "post-type", "taxonomy", "options-page"])
@@ -1515,23 +1906,28 @@ server.registerTool("diviops_scf_status", {
1515
1906
  .optional()
1516
1907
  .describe("List the individual pending items (key/title/type/action) instead of just counts."),
1517
1908
  },
1909
+ annotations: { idempotentHint: true },
1910
+ _meta: { idempotent: "true" },
1518
1911
  }, async ({ type, detailed }) => {
1519
- const gate = ensureWpCli();
1520
- if (!gate.ok) {
1521
- return { content: [{ type: "text", text: gate.text }] };
1522
- }
1523
- const args = ["scf", "json", "status", "--format=json"];
1524
- pushScfFlag(args, "type", type);
1525
- if (detailed)
1526
- args.push("--detailed");
1527
- const result = await wpCli.runArgs(args);
1528
- const output = result.success
1529
- ? result.output
1530
- : `Error: ${result.error}\n${result.output}`;
1531
- 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
+ };
1532
1928
  });
1533
1929
  server.registerTool("diviops_scf_export", {
1534
- 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'.",
1535
1931
  inputSchema: {
1536
1932
  dir: z
1537
1933
  .string()
@@ -1558,65 +1954,62 @@ server.registerTool("diviops_scf_export", {
1558
1954
  .optional()
1559
1955
  .describe("Comma-separated options-page def keys or admin titles. Requires ACF PRO."),
1560
1956
  },
1957
+ annotations: { idempotentHint: true },
1958
+ _meta: { idempotent: "true" },
1561
1959
  }, async ({ dir, stdout, field_groups, post_types, taxonomies, options_pages }) => {
1562
- const gate = ensureWpCli();
1563
- if (!gate.ok) {
1564
- return { content: [{ type: "text", text: gate.text }] };
1565
- }
1566
- if (!dir && !stdout) {
1567
- return {
1568
- content: [
1569
- {
1570
- type: "text",
1571
- text: "Error: pass either `dir` (absolute path under DIVIOPS_WP_CLI_SAFE_FS_ROOT) or `stdout: true`.",
1572
- },
1573
- ],
1574
- };
1575
- }
1576
- if (dir && stdout) {
1577
- return {
1578
- content: [
1579
- {
1580
- type: "text",
1581
- text: "Error: `dir` and `stdout` are mutually exclusive — pick one.",
1582
- },
1583
- ],
1584
- };
1585
- }
1586
- const args = ["scf", "json", "export"];
1587
- if (stdout)
1588
- args.push("--stdout");
1589
- pushScfFlag(args, "dir", dir);
1590
- pushScfFlag(args, "field-groups", field_groups);
1591
- pushScfFlag(args, "post-types", post_types);
1592
- pushScfFlag(args, "taxonomies", taxonomies);
1593
- pushScfFlag(args, "options-pages", options_pages);
1594
- const result = await wpCli.runArgs(args);
1595
- const output = result.success
1596
- ? result.output
1597
- : `Error: ${result.error}\n${result.output}`;
1598
- 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
+ };
1599
1986
  });
1600
1987
  server.registerTool("diviops_scf_import", {
1601
- 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'.",
1602
1989
  inputSchema: {
1603
1990
  file: z
1604
1991
  .string()
1605
1992
  .describe("Absolute path to the .json file to import. Must resolve under DIVIOPS_WP_CLI_SAFE_FS_ROOT."),
1606
1993
  },
1994
+ annotations: { idempotentHint: true },
1995
+ _meta: { idempotent: "true" },
1607
1996
  }, async ({ file }) => {
1608
- const gate = ensureWpCli();
1609
- if (!gate.ok) {
1610
- return { content: [{ type: "text", text: gate.text }] };
1611
- }
1612
- const result = await wpCli.runArgs(["scf", "json", "import", file]);
1613
- const output = result.success
1614
- ? result.output
1615
- : `Error: ${result.error}\n${result.output}`;
1616
- 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
+ };
1617
2010
  });
1618
2011
  server.registerTool("diviops_scf_sync", {
1619
- 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'.",
1620
2013
  inputSchema: {
1621
2014
  type: z
1622
2015
  .enum(["field-group", "post-type", "taxonomy", "options-page"])
@@ -1632,136 +2025,167 @@ server.registerTool("diviops_scf_sync", {
1632
2025
  .default(true)
1633
2026
  .describe("Preview pending changes without mutating the database. Defaults to true. Pass `false` to commit."),
1634
2027
  },
2028
+ annotations: { idempotentHint: true },
2029
+ _meta: { idempotent: "true" },
1635
2030
  }, async ({ type, key, dry_run }) => {
1636
- const gate = ensureWpCli();
1637
- if (!gate.ok) {
1638
- return { content: [{ type: "text", text: gate.text }] };
1639
- }
1640
- const args = ["scf", "json", "sync"];
1641
- pushScfFlag(args, "type", type);
1642
- pushScfFlag(args, "key", key);
1643
- if (dry_run !== false)
1644
- args.push("--dry-run");
1645
- const result = await wpCli.runArgs(args);
1646
- const output = result.success
1647
- ? result.output
1648
- : `Error: ${result.error}\n${result.output}`;
1649
- 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
+ };
1650
2053
  });
1651
2054
  server.registerTool("diviops_scf_field_group_list", {
1652
- 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" },
1653
2058
  }, async () => {
1654
- const gate = ensureWpCli();
1655
- if (!gate.ok) {
1656
- return { content: [{ type: "text", text: gate.text }] };
1657
- }
1658
- const result = await wpCli.runArgs([
1659
- "post",
1660
- "list",
1661
- "--post_type=acf-field-group",
1662
- "--post_status=any",
1663
- "--fields=ID,post_name,post_title,post_status,post_modified",
1664
- "--format=json",
1665
- ]);
1666
- const output = result.success
1667
- ? result.output
1668
- : `Error: ${result.error}\n${result.output}`;
1669
- 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
+ };
1670
2088
  });
1671
2089
  server.registerTool("diviops_scf_field_group_get", {
1672
- 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'.",
1673
2091
  inputSchema: {
1674
2092
  key: z
1675
2093
  .string()
1676
2094
  .describe("ACF field-group key (`group_abc123`, matched against post_name) or numeric WP post ID."),
1677
2095
  },
2096
+ annotations: { idempotentHint: true },
2097
+ _meta: { idempotent: "true" },
1678
2098
  }, async ({ key }) => {
1679
- const gate = ensureWpCli();
1680
- if (!gate.ok) {
1681
- return { content: [{ type: "text", text: gate.text }] };
1682
- }
1683
- // If the input looks like a numeric ID, hand it to `wp post get` directly.
1684
- // Otherwise treat it as an ACF key and resolve via post_name first.
1685
- const isNumericId = /^\d+$/.test(key);
1686
- if (isNumericId) {
1687
- const result = await wpCli.runArgs([
1688
- "post",
1689
- "get",
1690
- key,
1691
- "--format=json",
1692
- ]);
1693
- const output = result.success
1694
- ? result.output
1695
- : `Error: ${result.error}\n${result.output}`;
1696
- return { content: [{ type: "text", text: output }] };
1697
- }
1698
- // Resolve ACF key → post ID via `wp post list --name=<key>`. Single-row
1699
- // lookup; returns [] if the key isn't found.
1700
- const lookup = await wpCli.runArgs([
1701
- "post",
1702
- "list",
1703
- "--post_type=acf-field-group",
1704
- "--post_status=any",
1705
- `--name=${key}`,
1706
- "--fields=ID",
1707
- "--format=json",
1708
- ]);
1709
- if (!lookup.success) {
1710
- return {
1711
- content: [
1712
- {
1713
- type: "text",
1714
- text: `Error looking up field-group key "${key}": ${lookup.error}\n${lookup.output}`,
1715
- },
1716
- ],
1717
- };
1718
- }
1719
- let postId = null;
1720
- try {
1721
- const rows = JSON.parse(lookup.output);
1722
- if (Array.isArray(rows) && rows.length > 0) {
1723
- 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;
1724
2107
  }
1725
- }
1726
- catch {
1727
- // Fall through — postId stays null, return a clear "not found" error.
1728
- }
1729
- if (!postId) {
1730
- return {
1731
- content: [
1732
- {
1733
- type: "text",
1734
- 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).`,
1735
- },
1736
- ],
1737
- };
1738
- }
1739
- const result = await wpCli.runArgs([
1740
- "post",
1741
- "get",
1742
- postId,
1743
- "--format=json",
1744
- ]);
1745
- const output = result.success
1746
- ? result.output
1747
- : `Error: ${result.error}\n${result.output}`;
1748
- 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
+ };
1749
2163
  });
1750
2164
  // ── Connection ──────────────────────────────────────────────────────
1751
2165
  server.registerTool("diviops_meta_ping", {
1752
- 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" },
1753
2169
  }, async () => {
1754
- 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
+ });
1755
2177
  return {
1756
2178
  content: [
1757
- { type: "text", text: JSON.stringify(result) },
2179
+ { type: "text", text: serializeEnvelope(response) },
1758
2180
  ],
1759
2181
  };
1760
2182
  });
1761
2183
  server.registerTool("diviops_meta_info", {
1762
- 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" },
1763
2187
  }, async () => {
1764
- const info = {
2188
+ const response = await wrapResponse(async () => ({
1765
2189
  brand: "DiviOps",
1766
2190
  server: "diviops-mcp",
1767
2191
  version: SERVER_VERSION,
@@ -1780,10 +2204,10 @@ server.registerTool("diviops_meta_info", {
1780
2204
  "preview",
1781
2205
  ],
1782
2206
  wp_cli: wpCli ? wpCli.getAllowedCommands() : false,
1783
- };
2207
+ }));
1784
2208
  return {
1785
2209
  content: [
1786
- { type: "text", text: JSON.stringify(info) },
2210
+ { type: "text", text: serializeEnvelope(response) },
1787
2211
  ],
1788
2212
  };
1789
2213
  });
@@ -1882,47 +2306,48 @@ function loadTemplates() {
1882
2306
  const templates = loadTemplates();
1883
2307
  // Register a list tool so Claude can discover available templates
1884
2308
  server.registerTool("diviops_template_list", {
1885
- 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" },
1886
2312
  }, async () => {
1887
- const list = Array.from(templates.entries()).map(([name, t]) => ({
2313
+ const response = await wrapResponse(async () => Array.from(templates.entries()).map(([name, t]) => ({
1888
2314
  name,
1889
2315
  description: t.description,
1890
2316
  customizable: t.customizable,
1891
2317
  requires_css: t.requires_css ?? false,
1892
- }));
2318
+ })));
1893
2319
  return {
1894
- content: [{ type: "text", text: JSON.stringify(list) }],
2320
+ content: [
2321
+ { type: "text", text: serializeEnvelope(response) },
2322
+ ],
1895
2323
  };
1896
2324
  });
1897
2325
  server.registerTool("diviops_template_get", {
1898
- 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.",
1899
2327
  inputSchema: {
1900
2328
  template_name: z
1901
2329
  .string()
1902
2330
  .describe('Template name (e.g. "hero-centered", "hero-split", "hero-marquee", "features-blurbs", "cta-gradient", "cards-flex")'),
1903
2331
  },
2332
+ annotations: { idempotentHint: true },
2333
+ _meta: { idempotent: "true" },
1904
2334
  }, async ({ template_name }) => {
1905
- const template = templates.get(template_name);
1906
- if (!template) {
1907
- const available = Array.from(templates.keys()).join(", ");
1908
- return {
1909
- content: [
1910
- {
1911
- type: "text",
1912
- text: `Template "${template_name}" not found. Available: ${available}`,
1913
- },
1914
- ],
1915
- };
1916
- }
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
+ });
1917
2342
  return {
1918
2343
  content: [
1919
- { type: "text", text: JSON.stringify(template) },
2344
+ { type: "text", text: serializeEnvelope(response) },
1920
2345
  ],
1921
2346
  };
1922
2347
  });
1923
2348
  // ── Variable Manager CRUD ─────────────────────────────────────────────
1924
2349
  registerPluginTool("diviops_variable_list", {
1925
- 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'.",
1926
2351
  inputSchema: {
1927
2352
  type: z
1928
2353
  .enum(["colors", "numbers", "strings", "images", "links", "fonts"])
@@ -1933,21 +2358,24 @@ registerPluginTool("diviops_variable_list", {
1933
2358
  .optional()
1934
2359
  .describe('Filter by ID prefix (e.g. "gcid-oa-" for oa design system colors)'),
1935
2360
  },
2361
+ annotations: { idempotentHint: true },
2362
+ _meta: { idempotent: "true" },
1936
2363
  }, async ({ type, prefix }) => {
1937
2364
  const params = {};
1938
2365
  if (type)
1939
2366
  params.type = type;
1940
2367
  if (prefix)
1941
2368
  params.prefix = prefix;
1942
- const result = await wp.request("/variable/list", { params });
2369
+ const result = await wp.requestEnveloped("/variable/list", { params });
1943
2370
  return {
1944
2371
  content: [
1945
- { type: "text", text: JSON.stringify(result) },
2372
+ { type: "text", text: serializeEnvelope(result) },
1946
2373
  ],
1947
2374
  };
1948
2375
  });
1949
2376
  registerPluginTool("diviops_variable_create", {
1950
- 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,
1951
2379
  inputSchema: {
1952
2380
  type: z
1953
2381
  .enum(["colors", "numbers", "strings", "images", "links", "fonts"])
@@ -1987,8 +2415,11 @@ registerPluginTool("diviops_variable_create", {
1987
2415
  .positive()
1988
2416
  .optional()
1989
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,
1990
2419
  },
1991
- }, 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, }) => {
1992
2423
  const body = { type, label };
1993
2424
  if (value !== undefined)
1994
2425
  body.value = value;
@@ -2004,18 +2435,20 @@ registerPluginTool("diviops_variable_create", {
2004
2435
  body.output_unit = output_unit;
2005
2436
  if (root_font_size_px !== undefined)
2006
2437
  body.root_font_size_px = root_font_size_px;
2007
- const result = await wp.request("/variable/create", {
2438
+ if (dry_run)
2439
+ body.dry_run = true;
2440
+ const result = await wp.requestEnveloped("/variable/create", {
2008
2441
  method: "POST",
2009
2442
  body,
2010
2443
  });
2011
2444
  return {
2012
2445
  content: [
2013
- { type: "text", text: JSON.stringify(result) },
2446
+ { type: "text", text: serializeEnvelope(result) },
2014
2447
  ],
2015
2448
  };
2016
2449
  });
2017
2450
  registerPluginTool("diviops_variable_create_fluid_system", {
2018
- 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 }`.",
2019
2452
  inputSchema: {
2020
2453
  profile: z
2021
2454
  .enum(["divi-default", "wide", "custom"])
@@ -2152,6 +2585,8 @@ registerPluginTool("diviops_variable_create_fluid_system", {
2152
2585
  .default(false)
2153
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)."),
2154
2587
  },
2588
+ annotations: { idempotentHint: false },
2589
+ _meta: { idempotent: "false" },
2155
2590
  }, async ({ profile, custom_anchors, typography, spacing, radius, namespace, output_unit, root_font_size_px, dry_run, overwrite, }) => {
2156
2591
  const body = { profile };
2157
2592
  if (custom_anchors !== undefined)
@@ -2172,18 +2607,19 @@ registerPluginTool("diviops_variable_create_fluid_system", {
2172
2607
  body.dry_run = dry_run;
2173
2608
  if (overwrite !== undefined)
2174
2609
  body.overwrite = overwrite;
2175
- const result = await wp.request("/variable/create-fluid-system", {
2610
+ const result = await wp.requestEnveloped("/variable/create-fluid-system", {
2176
2611
  method: "POST",
2177
2612
  body,
2178
2613
  });
2179
2614
  return {
2180
2615
  content: [
2181
- { type: "text", text: JSON.stringify(result) },
2616
+ { type: "text", text: serializeEnvelope(result) },
2182
2617
  ],
2183
2618
  };
2184
2619
  });
2185
2620
  registerPluginTool("diviops_variable_delete", {
2186
- 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,
2187
2623
  inputSchema: {
2188
2624
  id: z
2189
2625
  .string()
@@ -2193,30 +2629,38 @@ registerPluginTool("diviops_variable_delete", {
2193
2629
  .optional()
2194
2630
  .default(false)
2195
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,
2196
2633
  },
2197
- }, async ({ id, force }) => {
2198
- 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", {
2199
2641
  method: "POST",
2200
- body: { id, force },
2642
+ body,
2201
2643
  });
2202
2644
  return {
2203
2645
  content: [
2204
- { type: "text", text: JSON.stringify(result) },
2646
+ { type: "text", text: serializeEnvelope(result) },
2205
2647
  ],
2206
2648
  };
2207
2649
  });
2208
2650
  registerPluginTool("diviops_variable_scan_orphans", {
2209
- 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" },
2210
2654
  }, async () => {
2211
- const result = await wp.request("/variable/scan-orphans");
2655
+ const result = await wp.requestEnveloped("/variable/scan-orphans");
2212
2656
  return {
2213
2657
  content: [
2214
- { type: "text", text: JSON.stringify(result) },
2658
+ { type: "text", text: serializeEnvelope(result) },
2215
2659
  ],
2216
2660
  };
2217
2661
  });
2218
2662
  registerPluginTool("diviops_variable_used_on_page", {
2219
- 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.",
2220
2664
  inputSchema: {
2221
2665
  post_id: z
2222
2666
  .number()
@@ -2224,16 +2668,20 @@ registerPluginTool("diviops_variable_used_on_page", {
2224
2668
  .positive()
2225
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."),
2226
2670
  },
2671
+ annotations: { idempotentHint: true },
2672
+ _meta: { idempotent: "true" },
2227
2673
  }, async ({ post_id }) => {
2228
- const result = await wp.request(`/variable/used-on-page/${post_id}`);
2674
+ const result = await wp.requestEnveloped(`/variable/used-on-page/${post_id}`);
2229
2675
  return {
2230
2676
  content: [
2231
- { type: "text", text: JSON.stringify(result) },
2677
+ { type: "text", text: serializeEnvelope(result) },
2232
2678
  ],
2233
2679
  };
2234
2680
  });
2235
2681
  registerPluginTool("diviops_meta_flush_cache", {
2236
- 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.",
2237
2685
  inputSchema: {
2238
2686
  post_id: z
2239
2687
  .number()
@@ -2252,8 +2700,11 @@ registerPluginTool("diviops_meta_flush_cache", {
2252
2700
  .positive()
2253
2701
  .optional()
2254
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,
2255
2704
  },
2256
- }, async ({ post_id, all, after }) => {
2705
+ annotations: { idempotentHint: true },
2706
+ _meta: { idempotent: "true" },
2707
+ }, async ({ post_id, all, after, dry_run }) => {
2257
2708
  const body = {};
2258
2709
  if (post_id !== undefined)
2259
2710
  body.post_id = post_id;
@@ -2261,13 +2712,15 @@ registerPluginTool("diviops_meta_flush_cache", {
2261
2712
  body.all = true;
2262
2713
  if (after !== undefined)
2263
2714
  body.after = after;
2264
- 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", {
2265
2718
  method: "POST",
2266
2719
  body,
2267
2720
  });
2268
2721
  return {
2269
2722
  content: [
2270
- { type: "text", text: JSON.stringify(result) },
2723
+ { type: "text", text: serializeEnvelope(result) },
2271
2724
  ],
2272
2725
  };
2273
2726
  });