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