@diviops/mcp-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1270 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Divi 5 MCP Server
4
+ *
5
+ * Exposes Divi Visual Builder operations as MCP tools for Claude.
6
+ * Requires the companion WordPress plugin "diviops-agent" to be active.
7
+ *
8
+ * Auth: WordPress Application Passwords (Basic Auth).
9
+ * Config: Environment variables WP_URL, WP_USER, WP_APP_PASSWORD.
10
+ */
11
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
+ import { z } from "zod";
14
+ import { WPClient } from "./wp-client.js";
15
+ import { optimizeSchema } from "./schema-optimizer.js";
16
+ import { createWpCli } from "./wp-cli.js";
17
+ import { readFileSync, readdirSync } from "fs";
18
+ import { join, dirname } from "path";
19
+ import { fileURLToPath } from "url";
20
+ const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ // ── Config ───────────────────────────────────────────────────────────
22
+ const WP_URL = process.env.WP_URL ?? "";
23
+ const WP_USER = process.env.WP_USER ?? "";
24
+ const WP_APP_PASSWORD = process.env.WP_APP_PASSWORD ?? "";
25
+ if (!WP_URL || !WP_USER || !WP_APP_PASSWORD) {
26
+ const missing = [
27
+ !WP_URL && "WP_URL",
28
+ !WP_USER && "WP_USER",
29
+ !WP_APP_PASSWORD && "WP_APP_PASSWORD",
30
+ ].filter(Boolean);
31
+ console.error(`Error: Missing required environment variable(s): ${missing.join(", ")}.\n` +
32
+ "Set WP_URL to your WordPress site URL (e.g. http://mysite.local).\n" +
33
+ "Generate an Application Password at: WP Admin → Users → Profile → Application Passwords");
34
+ process.exit(1);
35
+ }
36
+ const wp = new WPClient({
37
+ siteUrl: WP_URL,
38
+ username: WP_USER,
39
+ applicationPassword: WP_APP_PASSWORD,
40
+ });
41
+ // WP-CLI (optional — Local by Flywheel via WP_PATH, or custom wrapper via WP_CLI_CMD)
42
+ const WP_PATH = process.env.WP_PATH ?? "";
43
+ const WP_CLI_CMD = process.env.WP_CLI_CMD?.trim() ?? "";
44
+ const LOCAL_SITE_ID = process.env.LOCAL_SITE_ID ?? "";
45
+ let wpCli = null;
46
+ if (WP_CLI_CMD) {
47
+ try {
48
+ wpCli = createWpCli({
49
+ wpCliCmd: WP_CLI_CMD,
50
+ wpPath: WP_PATH || process.cwd(),
51
+ });
52
+ }
53
+ catch (e) {
54
+ console.error(`WP-CLI setup failed (non-fatal): ${e}`);
55
+ }
56
+ }
57
+ else if (WP_PATH) {
58
+ try {
59
+ wpCli = createWpCli({
60
+ wpPath: WP_PATH,
61
+ localSiteId: LOCAL_SITE_ID || undefined,
62
+ });
63
+ }
64
+ catch (e) {
65
+ console.error(`WP-CLI setup failed (non-fatal): ${e}`);
66
+ }
67
+ }
68
+ // ── Version ─────────────────────────────────────────────────────────
69
+ // Read version from package.json at startup — single source of truth.
70
+ const SERVER_VERSION = (() => {
71
+ try {
72
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
73
+ return pkg.version ?? "0.0.0";
74
+ }
75
+ catch {
76
+ return "0.0.0";
77
+ }
78
+ })();
79
+ // ── MCP Server ───────────────────────────────────────────────────────
80
+ const server = new McpServer({
81
+ name: "diviops-mcp",
82
+ version: SERVER_VERSION,
83
+ });
84
+ // ── Read Tools ───────────────────────────────────────────────────────
85
+ server.registerTool("diviops_list_pages", {
86
+ description: "List pages/posts in the WordPress site. Returns title, ID, URL, status, and whether each page uses Divi builder.",
87
+ inputSchema: {
88
+ post_type: z
89
+ .string()
90
+ .optional()
91
+ .default("page")
92
+ .describe('Post type to query: "page", "post", or custom type'),
93
+ per_page: z
94
+ .number()
95
+ .optional()
96
+ .default(20)
97
+ .describe("Number of results per page (max 100)"),
98
+ page: z.number().optional().default(1).describe("Page number"),
99
+ },
100
+ }, async ({ post_type, per_page, page }) => {
101
+ const result = await wp.request("/pages", {
102
+ params: {
103
+ post_type: post_type ?? "page",
104
+ per_page: String(per_page ?? 20),
105
+ page: String(page ?? 1),
106
+ },
107
+ });
108
+ return {
109
+ content: [
110
+ { type: "text", text: JSON.stringify(result, null, 2) },
111
+ ],
112
+ };
113
+ });
114
+ server.registerTool("diviops_get_page", {
115
+ description: "Get detailed info about a specific page including its raw Divi block content.",
116
+ inputSchema: {
117
+ page_id: z.number().describe("WordPress post/page ID"),
118
+ },
119
+ }, async ({ page_id }) => {
120
+ const result = await wp.request(`/page/${page_id}`);
121
+ return {
122
+ content: [
123
+ { type: "text", text: JSON.stringify(result, null, 2) },
124
+ ],
125
+ };
126
+ });
127
+ server.registerTool("diviops_get_page_layout", {
128
+ 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).",
129
+ inputSchema: {
130
+ page_id: z.number().describe("WordPress post/page ID"),
131
+ full: z
132
+ .boolean()
133
+ .optional()
134
+ .default(false)
135
+ .describe("Include full block attrs and raw content (default: false for slim mode)"),
136
+ },
137
+ }, async ({ page_id, full }) => {
138
+ const result = await wp.request(`/page/${page_id}/layout`, {
139
+ params: full ? { full: "true" } : {},
140
+ });
141
+ return {
142
+ content: [
143
+ { type: "text", text: JSON.stringify(result, null, 2) },
144
+ ],
145
+ };
146
+ });
147
+ server.registerTool("diviops_list_modules", {
148
+ 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.",
149
+ }, async () => {
150
+ const result = await wp.request("/modules");
151
+ return {
152
+ content: [
153
+ { type: "text", text: JSON.stringify(result, null, 2) },
154
+ ],
155
+ };
156
+ });
157
+ server.registerTool("diviops_get_module_schema", {
158
+ description: "Get the attribute schema for a Divi module. Returns optimized schema by default (~70% smaller) with content-relevant fields only. Use raw: true for the full schema including CSS selectors and VB metadata.",
159
+ inputSchema: {
160
+ module_name: z
161
+ .string()
162
+ .describe('Module name, e.g. "text", "image", "accordion", or full "divi/text"'),
163
+ raw: z
164
+ .boolean()
165
+ .optional()
166
+ .default(false)
167
+ .describe("Return full schema including CSS selectors and VB metadata"),
168
+ },
169
+ }, async ({ module_name, raw }) => {
170
+ const result = await wp.request(`/module/${encodeURIComponent(module_name)}`);
171
+ const output = raw ? result : optimizeSchema(result);
172
+ return {
173
+ content: [
174
+ { type: "text", text: JSON.stringify(output, null, 2) },
175
+ ],
176
+ };
177
+ });
178
+ server.registerTool("diviops_get_settings", {
179
+ description: "Get Divi site settings including theme options, site info, and builder version. Useful for understanding the site context before generating content.",
180
+ }, async () => {
181
+ const result = await wp.request("/settings");
182
+ return {
183
+ content: [
184
+ { type: "text", text: JSON.stringify(result, null, 2) },
185
+ ],
186
+ };
187
+ });
188
+ server.registerTool("diviops_get_global_colors", {
189
+ description: "Get the global color palette defined in Divi. Returns all global colors that can be referenced by modules.",
190
+ }, async () => {
191
+ const result = await wp.request("/global-colors");
192
+ return {
193
+ content: [
194
+ { type: "text", text: JSON.stringify(result, null, 2) },
195
+ ],
196
+ };
197
+ });
198
+ server.registerTool("diviops_get_global_fonts", {
199
+ description: "Get the global font definitions from Divi settings.",
200
+ }, async () => {
201
+ const result = await wp.request("/global-fonts");
202
+ return {
203
+ content: [
204
+ { type: "text", text: JSON.stringify(result, null, 2) },
205
+ ],
206
+ };
207
+ });
208
+ server.registerTool("diviops_find_icon", {
209
+ 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.",
210
+ inputSchema: {
211
+ query: z
212
+ .string()
213
+ .describe('Search keyword (e.g. "rocket", "heart", "chart", "user")'),
214
+ type: z
215
+ .enum(["all", "fa", "divi"])
216
+ .optional()
217
+ .default("all")
218
+ .describe('Filter by icon type: "all", "fa" (Font Awesome), or "divi" (ETmodules)'),
219
+ limit: z
220
+ .number()
221
+ .optional()
222
+ .default(10)
223
+ .describe("Max results (default 10, max 50)"),
224
+ },
225
+ }, async ({ query, type, limit }) => {
226
+ const result = await wp.request(`/icons/search?q=${encodeURIComponent(query)}&type=${type ?? "all"}&limit=${limit ?? 10}`);
227
+ return {
228
+ content: [
229
+ { type: "text", text: JSON.stringify(result, null, 2) },
230
+ ],
231
+ };
232
+ });
233
+ // ── Write Tools ──────────────────────────────────────────────────────
234
+ server.registerTool("diviops_update_page_content", {
235
+ 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.",
236
+ inputSchema: {
237
+ page_id: z.number().describe("WordPress post/page ID to update"),
238
+ content: z
239
+ .string()
240
+ .describe("Full page content in WordPress block markup format (<!-- wp:divi/section -->...<!-- /wp:divi/section -->)"),
241
+ },
242
+ }, async ({ page_id, content }) => {
243
+ const result = await wp.request(`/page/${page_id}/content`, {
244
+ method: "POST",
245
+ body: { content },
246
+ });
247
+ return {
248
+ content: [
249
+ { type: "text", text: JSON.stringify(result, null, 2) },
250
+ ],
251
+ };
252
+ });
253
+ server.registerTool("diviops_render_preview", {
254
+ description: "Render Divi block markup to HTML. Use this to preview what the output will look like before saving. Useful for validation.",
255
+ inputSchema: {
256
+ content: z.string().describe("Divi block markup to render to HTML"),
257
+ },
258
+ }, async ({ content }) => {
259
+ const result = await wp.request("/render", {
260
+ method: "POST",
261
+ body: { content },
262
+ });
263
+ return {
264
+ content: [
265
+ { type: "text", text: JSON.stringify(result, null, 2) },
266
+ ],
267
+ };
268
+ });
269
+ server.registerTool("diviops_validate_blocks", {
270
+ description: "Validate Divi block markup before saving. Checks structure (malformed comments, unknown blocks, missing builderVersion), required attributes (layout display on containers), and known pitfalls (button padding path, icon.enable, gradient enabled/positions). Returns errors and warnings.",
271
+ inputSchema: {
272
+ content: z.string().describe("Divi block markup to validate"),
273
+ },
274
+ }, async ({ content }) => {
275
+ const result = await wp.request("/validate", {
276
+ method: "POST",
277
+ body: { content },
278
+ });
279
+ return {
280
+ content: [
281
+ { type: "text", text: JSON.stringify(result, null, 2) },
282
+ ],
283
+ };
284
+ });
285
+ server.registerTool("diviops_append_section", {
286
+ description: "Append a Divi section to an existing page without overwriting other content. Use this to incrementally build pages.",
287
+ inputSchema: {
288
+ page_id: z.number().describe("WordPress post/page ID"),
289
+ content: z
290
+ .string()
291
+ .describe("Section block markup to append (<!-- wp:divi/section ...-->...<!-- /wp:divi/section -->)"),
292
+ position: z
293
+ .enum(["start", "end"])
294
+ .optional()
295
+ .default("end")
296
+ .describe('Where to insert: "start" or "end" (default)'),
297
+ },
298
+ }, async ({ page_id, content, position }) => {
299
+ const result = await wp.request(`/page/${page_id}/append`, {
300
+ method: "POST",
301
+ body: { content, position: position ?? "end" },
302
+ });
303
+ return {
304
+ content: [
305
+ { type: "text", text: JSON.stringify(result, null, 2) },
306
+ ],
307
+ };
308
+ });
309
+ server.registerTool("diviops_replace_section", {
310
+ description: "Replace a section on a page. Target by admin label OR text content. Use occurrence when multiple sections match.",
311
+ inputSchema: {
312
+ page_id: z.number().describe("WordPress post/page ID"),
313
+ label: z
314
+ .string()
315
+ .optional()
316
+ .describe("Admin label of the section to replace"),
317
+ match_text: z
318
+ .string()
319
+ .optional()
320
+ .describe("Text to search for in section content (case-insensitive substring)"),
321
+ content: z
322
+ .string()
323
+ .describe("New section block markup to replace the matched section"),
324
+ occurrence: z
325
+ .number()
326
+ .int()
327
+ .min(1)
328
+ .optional()
329
+ .default(1)
330
+ .describe("Which match to target (1-based, default: 1)"),
331
+ },
332
+ }, async ({ page_id, label, match_text, content, occurrence }) => {
333
+ const body = { content, occurrence };
334
+ if (label)
335
+ body.label = label;
336
+ if (match_text)
337
+ body.match_text = match_text;
338
+ const result = await wp.request(`/page/${page_id}/replace-section`, {
339
+ method: "POST",
340
+ body,
341
+ });
342
+ return {
343
+ content: [
344
+ { type: "text", text: JSON.stringify(result, null, 2) },
345
+ ],
346
+ };
347
+ });
348
+ server.registerTool("diviops_remove_section", {
349
+ description: "Remove a section from a page. Target by admin label OR text content. Use occurrence when multiple sections match.",
350
+ inputSchema: {
351
+ page_id: z.number().describe("WordPress post/page ID"),
352
+ label: z
353
+ .string()
354
+ .optional()
355
+ .describe("Admin label of the section to remove"),
356
+ match_text: z
357
+ .string()
358
+ .optional()
359
+ .describe("Text to search for in section content (case-insensitive substring)"),
360
+ occurrence: z
361
+ .number()
362
+ .int()
363
+ .min(1)
364
+ .optional()
365
+ .default(1)
366
+ .describe("Which match to target (1-based, default: 1)"),
367
+ },
368
+ }, async ({ page_id, label, match_text, occurrence }) => {
369
+ const body = { occurrence };
370
+ if (label)
371
+ body.label = label;
372
+ if (match_text)
373
+ body.match_text = match_text;
374
+ const result = await wp.request(`/page/${page_id}/remove-section`, {
375
+ method: "POST",
376
+ body,
377
+ });
378
+ return {
379
+ content: [
380
+ { type: "text", text: JSON.stringify(result, null, 2) },
381
+ ],
382
+ };
383
+ });
384
+ server.registerTool("diviops_get_section", {
385
+ 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.",
386
+ inputSchema: {
387
+ page_id: z.number().describe("WordPress post/page ID"),
388
+ label: z
389
+ .string()
390
+ .optional()
391
+ .describe("Admin label of the section to retrieve"),
392
+ match_text: z
393
+ .string()
394
+ .optional()
395
+ .describe("Text to search for in section content (case-insensitive substring)"),
396
+ occurrence: z
397
+ .number()
398
+ .int()
399
+ .min(1)
400
+ .optional()
401
+ .default(1)
402
+ .describe("Which match to target (1-based, default: 1)"),
403
+ },
404
+ }, async ({ page_id, label, match_text, occurrence }) => {
405
+ const params = { occurrence: String(occurrence) };
406
+ if (label)
407
+ params.label = label;
408
+ if (match_text)
409
+ params.match_text = match_text;
410
+ const qs = new URLSearchParams(params).toString();
411
+ const result = await wp.request(`/page/${page_id}/get-section?${qs}`);
412
+ return {
413
+ content: [
414
+ { type: "text", text: JSON.stringify(result, null, 2) },
415
+ ],
416
+ };
417
+ });
418
+ server.registerTool("diviops_update_module", {
419
+ 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"}. Priority: auto_index > label > match_text. Use occurrence with label when duplicates exist.',
420
+ inputSchema: {
421
+ page_id: z.number().describe("WordPress post/page ID"),
422
+ label: z
423
+ .string()
424
+ .optional()
425
+ .describe("Admin label of the module (exact match)"),
426
+ match_text: z
427
+ .string()
428
+ .optional()
429
+ .describe("Text to find in module innerContent (case-insensitive substring, first match)"),
430
+ auto_index: z
431
+ .string()
432
+ .optional()
433
+ .describe('Auto-index target in "type:N" format (e.g. "text:5", "icon:3"). Get from diviops_get_page_layout. Takes priority over label/match_text.'),
434
+ occurrence: z
435
+ .number()
436
+ .int()
437
+ .min(1)
438
+ .optional()
439
+ .default(1)
440
+ .describe("Which occurrence to target when multiple modules share the same label (1-based)"),
441
+ attrs: z
442
+ .record(z.string(), z.any())
443
+ .describe("Attribute paths (dot notation) and their new values"),
444
+ },
445
+ }, async ({ page_id, label, match_text, auto_index, occurrence, attrs }) => {
446
+ const body = { attrs };
447
+ if (auto_index)
448
+ body.auto_index = auto_index;
449
+ if (label)
450
+ body.label = label;
451
+ if (match_text)
452
+ body.match_text = match_text;
453
+ if (occurrence > 1)
454
+ body.occurrence = occurrence;
455
+ const result = await wp.request(`/page/${page_id}/update-module`, {
456
+ method: "POST",
457
+ body,
458
+ });
459
+ return {
460
+ content: [
461
+ { type: "text", text: JSON.stringify(result, null, 2) },
462
+ ],
463
+ };
464
+ });
465
+ server.registerTool("diviops_move_module", {
466
+ 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.',
467
+ inputSchema: {
468
+ page_id: z.number().describe("WordPress post/page ID"),
469
+ source_label: z
470
+ .string()
471
+ .optional()
472
+ .describe("Admin label of the module to move"),
473
+ source_match_text: z
474
+ .string()
475
+ .optional()
476
+ .describe("Text to search for in source module (case-insensitive)"),
477
+ source_auto_index: z
478
+ .string()
479
+ .optional()
480
+ .describe('Auto-index of the module to move in "type:N" format (e.g. "text:3")'),
481
+ source_occurrence: z
482
+ .number()
483
+ .int()
484
+ .min(1)
485
+ .optional()
486
+ .default(1)
487
+ .describe("Which occurrence when multiple sources match by label (1-based)"),
488
+ target_label: z
489
+ .string()
490
+ .optional()
491
+ .describe("Admin label of the reference module"),
492
+ target_match_text: z
493
+ .string()
494
+ .optional()
495
+ .describe("Text to search for in target module (case-insensitive)"),
496
+ target_auto_index: z
497
+ .string()
498
+ .optional()
499
+ .describe('Auto-index of the reference module in "type:N" format (e.g. "text:5")'),
500
+ target_occurrence: z
501
+ .number()
502
+ .int()
503
+ .min(1)
504
+ .optional()
505
+ .default(1)
506
+ .describe("Which occurrence when multiple targets match by label (1-based)"),
507
+ position: z
508
+ .enum(["before", "after"])
509
+ .describe("Place the source before or after the target"),
510
+ },
511
+ }, async ({ page_id, source_label, source_match_text, source_auto_index, source_occurrence, target_label, target_match_text, target_auto_index, target_occurrence, position, }) => {
512
+ const body = { position };
513
+ if (source_label)
514
+ body.source_label = source_label;
515
+ if (source_match_text)
516
+ body.source_match_text = source_match_text;
517
+ if (source_auto_index)
518
+ body.source_auto_index = source_auto_index;
519
+ if (source_occurrence > 1)
520
+ body.source_occurrence = source_occurrence;
521
+ if (target_label)
522
+ body.target_label = target_label;
523
+ if (target_match_text)
524
+ body.target_match_text = target_match_text;
525
+ if (target_auto_index)
526
+ body.target_auto_index = target_auto_index;
527
+ if (target_occurrence > 1)
528
+ body.target_occurrence = target_occurrence;
529
+ const result = await wp.request(`/page/${page_id}/move-module`, {
530
+ method: "POST",
531
+ body,
532
+ });
533
+ return {
534
+ content: [
535
+ { type: "text", text: JSON.stringify(result, null, 2) },
536
+ ],
537
+ };
538
+ });
539
+ server.registerTool("diviops_create_page", {
540
+ description: "Create a new WordPress page, optionally with Divi block content.",
541
+ inputSchema: {
542
+ title: z.string().describe("Page title"),
543
+ content: z
544
+ .string()
545
+ .optional()
546
+ .default("")
547
+ .describe("Page content in Divi block markup format"),
548
+ status: z
549
+ .enum(["draft", "publish", "private"])
550
+ .optional()
551
+ .default("draft")
552
+ .describe("Post status"),
553
+ },
554
+ }, async ({ title, content, status }) => {
555
+ const result = await wp.request("/page/create", {
556
+ method: "POST",
557
+ body: { title, content: content ?? "", status: status ?? "draft" },
558
+ });
559
+ return {
560
+ content: [
561
+ { type: "text", text: JSON.stringify(result, null, 2) },
562
+ ],
563
+ };
564
+ });
565
+ // ── Preset Tools ────────────────────────────────────────────────────
566
+ server.registerTool("diviops_preset_audit", {
567
+ description: "Audit all Divi module presets. Returns counts and details of spam vs descriptive presets, which are referenced by pages, and which are orphaned.",
568
+ }, async () => {
569
+ const result = await wp.request("/preset-audit");
570
+ return {
571
+ content: [
572
+ { type: "text", text: JSON.stringify(result, null, 2) },
573
+ ],
574
+ };
575
+ });
576
+ server.registerTool("diviops_preset_cleanup", {
577
+ 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.',
578
+ inputSchema: {
579
+ dry_run: z
580
+ .boolean()
581
+ .optional()
582
+ .default(true)
583
+ .describe("If true, preview changes without applying. Set false to execute."),
584
+ dedup: z
585
+ .boolean()
586
+ .optional()
587
+ .default(false)
588
+ .describe("Remove duplicate presets with identical attrs within the same module."),
589
+ action: z
590
+ .string()
591
+ .optional()
592
+ .describe('Action: "rename_strip_prefix" strips a prefix, "remove_orphans" removes unreferenced presets.'),
593
+ prefix: z
594
+ .string()
595
+ .optional()
596
+ .describe('Prefix to strip when action is "rename_strip_prefix" (e.g. "Online Courses ").'),
597
+ scope: z
598
+ .enum(["spam", "all"])
599
+ .default("spam")
600
+ .describe('Scope for remove_orphans: "spam" (only spam-named orphans) or "all" (all non-default orphans).'),
601
+ },
602
+ }, async ({ dry_run, dedup, action, prefix, scope }) => {
603
+ const body = { dry_run: dry_run ?? true };
604
+ if (dedup)
605
+ body.dedup = true;
606
+ if (action)
607
+ body.action = action;
608
+ if (prefix)
609
+ body.prefix = prefix;
610
+ if (action === "remove_orphans" && scope)
611
+ body.scope = scope;
612
+ const result = await wp.request("/preset-cleanup", {
613
+ method: "POST",
614
+ body,
615
+ });
616
+ return {
617
+ content: [
618
+ { type: "text", text: JSON.stringify(result, null, 2) },
619
+ ],
620
+ };
621
+ });
622
+ server.registerTool("diviops_preset_update", {
623
+ description: "Update a specific preset by ID. Can rename and/or replace its style attributes.",
624
+ inputSchema: {
625
+ preset_id: z.string().describe("Preset ID (UUID or short ID)"),
626
+ name: z.string().optional().describe("New display name for the preset"),
627
+ attrs: z
628
+ .record(z.string(), z.any())
629
+ .optional()
630
+ .describe("New style attributes (replaces both attrs and styleAttrs)"),
631
+ },
632
+ }, async ({ preset_id, name, attrs }) => {
633
+ const body = { preset_id };
634
+ if (name)
635
+ body.name = name;
636
+ if (attrs)
637
+ body.attrs = attrs;
638
+ const result = await wp.request("/preset-update", {
639
+ method: "POST",
640
+ body,
641
+ });
642
+ return {
643
+ content: [
644
+ { type: "text", text: JSON.stringify(result, null, 2) },
645
+ ],
646
+ };
647
+ });
648
+ server.registerTool("diviops_preset_delete", {
649
+ description: "Delete a specific preset by ID. Use diviops_preset_audit first to verify the preset is unreferenced before deleting.",
650
+ inputSchema: {
651
+ preset_id: z.string().describe("Preset ID to delete"),
652
+ },
653
+ }, async ({ preset_id }) => {
654
+ const result = await wp.request("/preset-delete", {
655
+ method: "POST",
656
+ body: { preset_id },
657
+ });
658
+ return {
659
+ content: [
660
+ { type: "text", text: JSON.stringify(result, null, 2) },
661
+ ],
662
+ };
663
+ });
664
+ // ── Library Tools ───────────────────────────────────────────────────
665
+ server.registerTool("diviops_list_library", {
666
+ description: "List saved Divi Library items. Filter by layout_type (section, row, module) and scope (global, non_global).",
667
+ inputSchema: {
668
+ layout_type: z
669
+ .string()
670
+ .optional()
671
+ .describe('Filter by type: "section", "row", "module", or empty for all'),
672
+ scope: z
673
+ .string()
674
+ .optional()
675
+ .describe('Filter by scope: "global", "non_global", or empty for all'),
676
+ per_page: z
677
+ .number()
678
+ .optional()
679
+ .default(50)
680
+ .describe("Max results (default 50)"),
681
+ },
682
+ }, async ({ layout_type, scope, per_page }) => {
683
+ const params = {};
684
+ if (layout_type)
685
+ params.layout_type = layout_type;
686
+ if (scope)
687
+ params.scope = scope;
688
+ if (per_page)
689
+ params.per_page = String(per_page);
690
+ const result = await wp.request("/library", { params });
691
+ return {
692
+ content: [
693
+ { type: "text", text: JSON.stringify(result, null, 2) },
694
+ ],
695
+ };
696
+ });
697
+ server.registerTool("diviops_get_library_item", {
698
+ description: "Get a Divi Library item's content by ID. Returns the raw block markup that can be used with diviops_append_section or diviops_update_page_content.",
699
+ inputSchema: {
700
+ item_id: z.number().describe("Library item ID"),
701
+ },
702
+ }, async ({ item_id }) => {
703
+ const result = await wp.request(`/library/${item_id}`);
704
+ return {
705
+ content: [
706
+ { type: "text", text: JSON.stringify(result, null, 2) },
707
+ ],
708
+ };
709
+ });
710
+ server.registerTool("diviops_save_to_library", {
711
+ description: 'Save Divi block markup to the Divi Library for reuse. Saved items appear in the VB\'s "Add From Library" panel.',
712
+ inputSchema: {
713
+ title: z.string().describe("Display name for the library item"),
714
+ content: z
715
+ .string()
716
+ .describe("Block markup to save (section, row, or module)"),
717
+ layout_type: z
718
+ .enum(["section", "row", "module"])
719
+ .optional()
720
+ .default("section")
721
+ .describe('Type of layout: "section", "row", or "module"'),
722
+ scope: z
723
+ .enum(["global", "non_global"])
724
+ .optional()
725
+ .default("non_global")
726
+ .describe('"global" = synced across all uses, "non_global" = independent copies'),
727
+ },
728
+ }, async ({ title, content, layout_type, scope }) => {
729
+ const result = await wp.request("/library/save", {
730
+ method: "POST",
731
+ body: {
732
+ title,
733
+ content,
734
+ layout_type,
735
+ scope,
736
+ },
737
+ });
738
+ return {
739
+ content: [
740
+ { type: "text", text: JSON.stringify(result, null, 2) },
741
+ ],
742
+ };
743
+ });
744
+ // ── Theme Builder Tools ─────────────────────────────────────────────
745
+ server.registerTool("diviops_list_tb_templates", {
746
+ description: "List all Theme Builder templates with their conditions, layout IDs, and enabled status. Shows which template applies to which pages/post types.",
747
+ inputSchema: {
748
+ per_page: z
749
+ .number()
750
+ .max(100)
751
+ .optional()
752
+ .default(50)
753
+ .describe("Results per page (max 100)"),
754
+ page: z.number().optional().default(1).describe("Page number"),
755
+ },
756
+ }, async ({ per_page, page }) => {
757
+ const params = {};
758
+ if (per_page)
759
+ params.per_page = String(per_page);
760
+ if (page)
761
+ params.page = String(page);
762
+ const result = await wp.request("/theme-builder/templates", { params });
763
+ return {
764
+ content: [
765
+ { type: "text", text: JSON.stringify(result, null, 2) },
766
+ ],
767
+ };
768
+ });
769
+ server.registerTool("diviops_get_tb_layout", {
770
+ description: "Get a Theme Builder layout's block markup content (header, body, or footer). Use the layout IDs from diviops_list_tb_templates.",
771
+ inputSchema: {
772
+ layout_id: z
773
+ .number()
774
+ .describe("Layout post ID (from template header_layout_id, body_layout_id, or footer_layout_id)"),
775
+ },
776
+ }, async ({ layout_id }) => {
777
+ const result = await wp.request(`/theme-builder/layout/${layout_id}`);
778
+ return {
779
+ content: [
780
+ { type: "text", text: JSON.stringify(result, null, 2) },
781
+ ],
782
+ };
783
+ });
784
+ server.registerTool("diviops_update_tb_layout", {
785
+ description: "Update a Theme Builder layout's block markup (header, body, or footer). Replaces the full content.",
786
+ inputSchema: {
787
+ layout_id: z.number().describe("Layout post ID to update"),
788
+ content: z.string().describe("New block markup content"),
789
+ },
790
+ }, async ({ layout_id, content }) => {
791
+ const result = await wp.request(`/theme-builder/layout/${layout_id}`, {
792
+ method: "PUT",
793
+ body: { content },
794
+ });
795
+ return {
796
+ content: [
797
+ { type: "text", text: JSON.stringify(result, null, 2) },
798
+ ],
799
+ };
800
+ });
801
+ server.registerTool("diviops_create_tb_template", {
802
+ description: "Create a Theme Builder template with custom header and/or footer. Automatically creates layout posts, sets conditions, and links to Theme Builder.",
803
+ inputSchema: {
804
+ title: z.string().describe('Template name (e.g. "Landing Pages")'),
805
+ condition: z
806
+ .string()
807
+ .describe('Condition string (e.g. "singular:post_type:page:all", "singular:post_type:project:all", "archive:taxonomy:category:all")'),
808
+ header_content: z
809
+ .string()
810
+ .optional()
811
+ .default("")
812
+ .describe("Header block markup (empty = inherit from default template)"),
813
+ footer_content: z
814
+ .string()
815
+ .optional()
816
+ .default("")
817
+ .describe("Footer block markup (empty = inherit from default template)"),
818
+ },
819
+ }, async ({ title, condition, header_content, footer_content }) => {
820
+ const result = await wp.request("/theme-builder/template", {
821
+ method: "POST",
822
+ body: { title, condition, header_content, footer_content },
823
+ });
824
+ return {
825
+ content: [
826
+ { type: "text", text: JSON.stringify(result, null, 2) },
827
+ ],
828
+ };
829
+ });
830
+ // ── Canvas Tools ────────────────────────────────────────────────────
831
+ server.registerTool("diviops_create_canvas", {
832
+ description: "Create a canvas (off-canvas workspace) linked to a page. Used for popups, off-canvas menus, modals. Content uses standard Divi block markup.",
833
+ inputSchema: {
834
+ title: z
835
+ .string()
836
+ .describe('Canvas name (e.g. "Popup Menu", "Modal Contact Form")'),
837
+ parent_page_id: z.number().describe("Parent page post ID"),
838
+ content: z
839
+ .string()
840
+ .optional()
841
+ .default("")
842
+ .describe("Divi block markup for canvas content"),
843
+ canvas_id: z
844
+ .string()
845
+ .optional()
846
+ .describe("Canvas UUID (auto-generated if omitted)"),
847
+ append_to_main: z
848
+ .enum(["above", "below"])
849
+ .optional()
850
+ .describe("Auto-append position relative to main content"),
851
+ z_index: z
852
+ .number()
853
+ .optional()
854
+ .describe("Layering order (higher = on top)"),
855
+ },
856
+ }, async ({ title, parent_page_id, content, canvas_id, append_to_main, z_index, }) => {
857
+ const body = {
858
+ title,
859
+ parent_page_id,
860
+ content: content ?? "",
861
+ };
862
+ if (canvas_id)
863
+ body.canvas_id = canvas_id;
864
+ if (append_to_main)
865
+ body.append_to_main = append_to_main;
866
+ if (z_index !== undefined)
867
+ body.z_index = z_index;
868
+ const result = await wp.request("/canvas/create", { method: "POST", body });
869
+ return {
870
+ content: [
871
+ { type: "text", text: JSON.stringify(result, null, 2) },
872
+ ],
873
+ };
874
+ });
875
+ server.registerTool("diviops_list_canvases", {
876
+ description: "List canvases (off-canvas workspaces). Filter by parent page or list all.",
877
+ inputSchema: {
878
+ parent_page_id: z
879
+ .number()
880
+ .optional()
881
+ .describe("Filter by parent page ID (omit for all canvases)"),
882
+ per_page: z
883
+ .number()
884
+ .int()
885
+ .min(1)
886
+ .max(100)
887
+ .optional()
888
+ .default(50)
889
+ .describe("Max results (default 50, 1-100)"),
890
+ },
891
+ }, async ({ parent_page_id, per_page }) => {
892
+ const params = {};
893
+ if (parent_page_id)
894
+ params.parent_page_id = String(parent_page_id);
895
+ if (per_page)
896
+ params.per_page = String(per_page);
897
+ const result = await wp.request("/canvases", { params });
898
+ return {
899
+ content: [
900
+ { type: "text", text: JSON.stringify(result, null, 2) },
901
+ ],
902
+ };
903
+ });
904
+ server.registerTool("diviops_get_canvas", {
905
+ description: "Get a canvas's block content and metadata.",
906
+ inputSchema: {
907
+ canvas_post_id: z
908
+ .number()
909
+ .describe("Canvas post ID (from diviops_list_canvases)"),
910
+ },
911
+ }, async ({ canvas_post_id }) => {
912
+ const result = await wp.request(`/canvas/${canvas_post_id}`);
913
+ return {
914
+ content: [
915
+ { type: "text", text: JSON.stringify(result, null, 2) },
916
+ ],
917
+ };
918
+ });
919
+ server.registerTool("diviops_update_canvas", {
920
+ description: "Update a canvas's content and/or metadata. Content replaces the entire canvas.",
921
+ inputSchema: {
922
+ canvas_post_id: z.number().describe("Canvas post ID"),
923
+ content: z
924
+ .string()
925
+ .optional()
926
+ .describe("New block markup (replaces entire content)"),
927
+ title: z.string().optional().describe("New canvas title"),
928
+ append_to_main: z
929
+ .enum(["above", "below", ""])
930
+ .optional()
931
+ .describe('Append position: "above", "below", or "" to clear'),
932
+ z_index: z.number().optional().describe("Layering order"),
933
+ },
934
+ }, async ({ canvas_post_id, content, title, append_to_main, z_index }) => {
935
+ const body = {};
936
+ if (content !== undefined)
937
+ body.content = content;
938
+ if (title !== undefined)
939
+ body.title = title;
940
+ if (append_to_main !== undefined)
941
+ body.append_to_main = append_to_main;
942
+ if (z_index !== undefined)
943
+ body.z_index = z_index;
944
+ const result = await wp.request(`/canvas/${canvas_post_id}`, {
945
+ method: "POST",
946
+ body,
947
+ });
948
+ return {
949
+ content: [
950
+ { type: "text", text: JSON.stringify(result, null, 2) },
951
+ ],
952
+ };
953
+ });
954
+ server.registerTool("diviops_delete_canvas", {
955
+ description: "Delete a canvas. This permanently removes the canvas post.",
956
+ inputSchema: {
957
+ canvas_post_id: z.number().describe("Canvas post ID to delete"),
958
+ },
959
+ }, async ({ canvas_post_id }) => {
960
+ const result = await wp.request(`/canvas/${canvas_post_id}`, {
961
+ method: "DELETE",
962
+ });
963
+ return {
964
+ content: [
965
+ { type: "text", text: JSON.stringify(result, null, 2) },
966
+ ],
967
+ };
968
+ });
969
+ // ── WP-CLI ──────────────────────────────────────────────────────────
970
+ server.registerTool("diviops_wp_cli", {
971
+ description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel). Commands validated against a safety allowlist. Default tier: read commands, post create/update, post meta read/write, cache/rewrite flush, term create. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var): option update, post delete, post meta delete, plugin activate/deactivate, eval-file. Use --format=json for structured output.",
972
+ inputSchema: {
973
+ command: z
974
+ .string()
975
+ .describe('WP-CLI command without the "wp" prefix. E.g. "option get blogname", "post list --format=json", "db query \\"SELECT COUNT(*) FROM wp_posts\\""'),
976
+ },
977
+ }, async ({ command }) => {
978
+ if (!wpCli) {
979
+ return {
980
+ content: [
981
+ {
982
+ type: "text",
983
+ 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.',
984
+ },
985
+ ],
986
+ };
987
+ }
988
+ const result = await wpCli.run(command);
989
+ const output = result.success
990
+ ? result.output
991
+ : `Error: ${result.error}\n${result.output}`;
992
+ return { content: [{ type: "text", text: output }] };
993
+ });
994
+ // ── Connection ──────────────────────────────────────────────────────
995
+ server.registerTool("diviops_test_connection", {
996
+ description: "Test the connection to the WordPress site and verify the Divi MCP plugin is active.",
997
+ }, async () => {
998
+ const result = await wp.testConnection();
999
+ return {
1000
+ content: [
1001
+ { type: "text", text: JSON.stringify(result, null, 2) },
1002
+ ],
1003
+ };
1004
+ });
1005
+ server.registerTool("diviops_server_info", {
1006
+ description: "Returns DiviOps MCP server identity, version, license type, and available capabilities.",
1007
+ }, async () => {
1008
+ const info = {
1009
+ brand: "DiviOps",
1010
+ server: "diviops-mcp",
1011
+ version: SERVER_VERSION,
1012
+ license: "MIT",
1013
+ capabilities: [
1014
+ "pages",
1015
+ "modules",
1016
+ "presets",
1017
+ "library",
1018
+ "theme_builder",
1019
+ "canvas",
1020
+ "variables",
1021
+ "templates",
1022
+ "icons",
1023
+ "validation",
1024
+ "preview",
1025
+ ],
1026
+ wp_cli: wpCli ? wpCli.getAllowedCommands() : false,
1027
+ };
1028
+ return {
1029
+ content: [
1030
+ { type: "text", text: JSON.stringify(info, null, 2) },
1031
+ ],
1032
+ };
1033
+ });
1034
+ // ── Resources ────────────────────────────────────────────────────────
1035
+ server.resource("divi-block-format-guide", "divi://block-format-guide", async () => ({
1036
+ contents: [
1037
+ {
1038
+ uri: "divi://block-format-guide",
1039
+ mimeType: "text/markdown",
1040
+ text: BLOCK_FORMAT_GUIDE,
1041
+ },
1042
+ ],
1043
+ }));
1044
+ const BLOCK_FORMAT_GUIDE = `# Divi 5 Block Markup Format
1045
+
1046
+ Divi 5 uses WordPress block markup (Gutenberg-style comments) to define layouts.
1047
+
1048
+ ## Basic Structure
1049
+
1050
+ Every Divi layout follows this hierarchy:
1051
+ \`\`\`
1052
+ Section → Row → Column → Module
1053
+ \`\`\`
1054
+
1055
+ ## Example: Simple Text Section
1056
+
1057
+ \`\`\`html
1058
+ <!-- wp:divi/section -->
1059
+ <!-- wp:divi/row -->
1060
+ <!-- wp:divi/column -->
1061
+ <!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Heading"}}},"advanced":{"text":{"text":{"desktop":{"value":"<h1>Hello World</h1><p>This is a paragraph.</p>"}}}}}} -->
1062
+ <!-- /wp:divi/text -->
1063
+ <!-- /wp:divi/column -->
1064
+ <!-- /wp:divi/row -->
1065
+ <!-- /wp:divi/section -->
1066
+ \`\`\`
1067
+
1068
+ ## Key Patterns
1069
+
1070
+ ### Module Attributes
1071
+ Attributes are JSON in the block comment. Structure:
1072
+ - \`module.meta\` — Admin label, visibility, etc.
1073
+ - \`module.advanced\` — Content settings (text, links, etc.)
1074
+ - \`module.decoration\` — Design/style settings (colors, fonts, spacing)
1075
+
1076
+ ### Multi-Column Layout
1077
+ \`\`\`html
1078
+ <!-- wp:divi/section -->
1079
+ <!-- wp:divi/row -->
1080
+ <!-- wp:divi/column {"attrs":{"type":"1_2"}} -->
1081
+ <!-- wp:divi/text ... --><!-- /wp:divi/text -->
1082
+ <!-- /wp:divi/column -->
1083
+ <!-- wp:divi/column {"attrs":{"type":"1_2"}} -->
1084
+ <!-- wp:divi/image ... --><!-- /wp:divi/image -->
1085
+ <!-- /wp:divi/column -->
1086
+ <!-- /wp:divi/row -->
1087
+ <!-- /wp:divi/section -->
1088
+ \`\`\`
1089
+
1090
+ ### Common Modules
1091
+ - \`divi/text\` — Rich text content
1092
+ - \`divi/image\` — Images
1093
+ - \`divi/button\` — CTA buttons
1094
+ - \`divi/heading\` — Headings
1095
+ - \`divi/blurb\` — Icon + text cards
1096
+ - \`divi/accordion\` — Collapsible sections
1097
+ - \`divi/slider\` — Slide carousels
1098
+ - \`divi/gallery\` — Image galleries
1099
+ - \`divi/video\` — Video embeds
1100
+ - \`divi/divider\` — Visual separators
1101
+ - \`divi/cta\` — Call to action blocks
1102
+
1103
+ ## Tips
1104
+ 1. Always use \`diviops_get_module_schema\` to check exact attribute names before building markup.
1105
+ 2. Use \`diviops_get_page_layout\` on existing pages to learn the format from real examples.
1106
+ 3. Use \`diviops_render_preview\` to validate markup before saving.
1107
+ `;
1108
+ // ── Template Resources ──────────────────────────────────────────────
1109
+ const templatesDir = join(__dirname, "..", "templates");
1110
+ function loadTemplates() {
1111
+ const templates = new Map();
1112
+ try {
1113
+ const files = readdirSync(templatesDir).filter((f) => f.endsWith(".json"));
1114
+ for (const file of files) {
1115
+ const content = readFileSync(join(templatesDir, file), "utf-8");
1116
+ const template = JSON.parse(content);
1117
+ const name = file.replace(".json", "");
1118
+ templates.set(name, template);
1119
+ }
1120
+ }
1121
+ catch (e) {
1122
+ console.error("Warning: Could not load templates:", e);
1123
+ }
1124
+ return templates;
1125
+ }
1126
+ const templates = loadTemplates();
1127
+ // Register a list tool so Claude can discover available templates
1128
+ server.registerTool("diviops_list_templates", {
1129
+ description: "List available Divi page section templates. Each template contains verified block markup patterns that can be used as a base for page generation.",
1130
+ }, async () => {
1131
+ const list = Array.from(templates.entries()).map(([name, t]) => ({
1132
+ name,
1133
+ description: t.description,
1134
+ customizable: t.customizable,
1135
+ requires_css: t.requires_css ?? false,
1136
+ }));
1137
+ return {
1138
+ content: [{ type: "text", text: JSON.stringify(list, null, 2) }],
1139
+ };
1140
+ });
1141
+ server.registerTool("diviops_get_template", {
1142
+ description: "Get a specific Divi template with verified block markup, customizable variables, and usage notes. Use this to generate pages based on proven patterns.",
1143
+ inputSchema: {
1144
+ template_name: z
1145
+ .string()
1146
+ .describe('Template name (e.g. "hero-centered", "hero-split", "hero-marquee", "features-blurbs", "cta-gradient", "cards-flex")'),
1147
+ },
1148
+ }, async ({ template_name }) => {
1149
+ const template = templates.get(template_name);
1150
+ if (!template) {
1151
+ const available = Array.from(templates.keys()).join(", ");
1152
+ return {
1153
+ content: [
1154
+ {
1155
+ type: "text",
1156
+ text: `Template "${template_name}" not found. Available: ${available}`,
1157
+ },
1158
+ ],
1159
+ };
1160
+ }
1161
+ return {
1162
+ content: [
1163
+ { type: "text", text: JSON.stringify(template, null, 2) },
1164
+ ],
1165
+ };
1166
+ });
1167
+ // ── Variable Manager CRUD ─────────────────────────────────────────────
1168
+ server.registerTool("diviops_list_variables", {
1169
+ 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.",
1170
+ inputSchema: {
1171
+ type: z
1172
+ .enum(["colors", "numbers", "strings", "images", "links", "fonts"])
1173
+ .optional()
1174
+ .describe("Filter by variable type"),
1175
+ prefix: z
1176
+ .string()
1177
+ .optional()
1178
+ .describe('Filter by ID prefix (e.g. "gcid-oa-" for oa design system colors)'),
1179
+ },
1180
+ }, async ({ type, prefix }) => {
1181
+ const params = {};
1182
+ if (type)
1183
+ params.type = type;
1184
+ if (prefix)
1185
+ params.prefix = prefix;
1186
+ const result = await wp.request("/variables", { params });
1187
+ return {
1188
+ content: [
1189
+ { type: "text", text: JSON.stringify(result, null, 2) },
1190
+ ],
1191
+ };
1192
+ });
1193
+ server.registerTool("diviops_create_variable", {
1194
+ 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.',
1195
+ inputSchema: {
1196
+ type: z
1197
+ .enum(["colors", "numbers", "strings", "images", "links", "fonts"])
1198
+ .describe("Variable type"),
1199
+ id: z
1200
+ .string()
1201
+ .optional()
1202
+ .describe('Variable ID (e.g. "gcid-oa-accent" for colors, "gvid-oa-size-xl" for numbers). Auto-generated if omitted.'),
1203
+ label: z
1204
+ .string()
1205
+ .describe("Human-readable label shown in the VB Variable Manager"),
1206
+ value: z
1207
+ .string()
1208
+ .describe('Variable value: hex color for colors (e.g. "#3a7a6a"), CSS value for numbers (e.g. "clamp(30px, 8vw, 100px)")'),
1209
+ },
1210
+ }, async ({ type, id, label, value }) => {
1211
+ const body = { type, label, value };
1212
+ if (id)
1213
+ body.id = id;
1214
+ const result = await wp.request("/variable/create", {
1215
+ method: "POST",
1216
+ body,
1217
+ });
1218
+ return {
1219
+ content: [
1220
+ { type: "text", text: JSON.stringify(result, null, 2) },
1221
+ ],
1222
+ };
1223
+ });
1224
+ server.registerTool("diviops_delete_variable", {
1225
+ description: "Delete a design token variable by ID. Auto-detects storage from ID prefix (gcid-* = colors, gvid-* = numbers/strings/etc).",
1226
+ inputSchema: {
1227
+ id: z
1228
+ .string()
1229
+ .describe('Variable ID to delete (e.g. "gcid-oa-accent" or "gvid-oa-size-xl")'),
1230
+ },
1231
+ }, async ({ id }) => {
1232
+ const result = await wp.request("/variable/delete", {
1233
+ method: "POST",
1234
+ body: { id },
1235
+ });
1236
+ return {
1237
+ content: [
1238
+ { type: "text", text: JSON.stringify(result, null, 2) },
1239
+ ],
1240
+ };
1241
+ });
1242
+ // ── Start ────────────────────────────────────────────────────────────
1243
+ async function main() {
1244
+ // Version handshake — verify plugin compatibility before accepting tool calls.
1245
+ try {
1246
+ const hs = await wp.handshake(SERVER_VERSION);
1247
+ const diviInfo = hs.divi.active
1248
+ ? `Divi ${hs.divi.version ?? "unknown"}`
1249
+ : "Divi not active";
1250
+ console.error(`Handshake OK: plugin ${hs.plugin_version}, ${diviInfo}, ${hs.capabilities.length} capabilities`);
1251
+ }
1252
+ catch (error) {
1253
+ const msg = error instanceof Error ? error.message : String(error);
1254
+ // Version mismatch — fatal (HTTP 426 from plugin, or client-side minimum check).
1255
+ if (msg.includes("WordPress API error (426)") ||
1256
+ msg.includes("below the minimum required")) {
1257
+ console.error(`Version mismatch: ${msg}`);
1258
+ process.exit(1);
1259
+ }
1260
+ // Other errors (network, auth) — warn but continue, tools will fail individually.
1261
+ console.error(`Handshake warning: ${msg}`);
1262
+ }
1263
+ const transport = new StdioServerTransport();
1264
+ await server.connect(transport);
1265
+ console.error("Divi MCP Server running on stdio");
1266
+ }
1267
+ main().catch((error) => {
1268
+ console.error("Fatal error:", error);
1269
+ process.exit(1);
1270
+ });