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