@diviops/mcp-server 1.0.0 → 1.2.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/README.md CHANGED
@@ -96,7 +96,7 @@ The server connects via standard WordPress REST API and works with any environme
96
96
 
97
97
  > **WP-CLI note:** `WP_PATH` keeps the existing Local by Flywheel behavior by running `wp` directly on the host filesystem. For Docker-based environments (DDEV, wp-env, DevKinsta, WordPress Studio), set `WP_CLI_CMD` to the wrapper command instead. When `WP_CLI_CMD` is set, the server executes the wrapper from `WP_PATH` if provided, otherwise from its current working directory. The MCP server still validates the requested WP-CLI subcommand against its allowlist before executing either path.
98
98
 
99
- ## Available Tools (63)
99
+ ## Available Tools (65)
100
100
 
101
101
  ### Read (30)
102
102
  | Tool | Description |
@@ -132,11 +132,13 @@ The server connects via standard WordPress REST API and works with any environme
132
132
  | `diviops_scf_field_group_list` | List all SCF/ACF field groups (post_name = ACF key, post_title, post_status, post_modified). Queries the `acf-field-group` post type via `wp post list` (works on SCF 6.8.4+ and older ACF) |
133
133
  | `diviops_scf_field_group_get` | Fetch a single SCF/ACF field-group post by ACF key (`group_abc123` → post_name) or numeric WP post ID. For the parsed/structured field tree, use `diviops_scf_export --field-groups=<key> --stdout` |
134
134
 
135
- ### Write (31)
135
+ ### Write (33)
136
136
  | Tool | Description |
137
137
  |------|-------------|
138
138
  | `diviops_page_create` | Create a new page with optional Divi content |
139
139
  | `diviops_page_update_content` | Full page content rewrite |
140
+ | `diviops_page_trash` | Trash (default) or permanently delete (`force=true`) a page. Idempotent on already-trashed posts. Supports `dry_run` |
141
+ | `diviops_page_update_status` | Update post_status (publish/draft/private/pending/future). `future` requires `date_gmt` (ISO 8601 UTC); `publish` clears stale future dates so re-publishing takes effect immediately. Supports `dry_run` |
140
142
  | `diviops_section_append` | Append a section to existing page (start or end) |
141
143
  | `diviops_section_replace` | Replace a section by admin label |
142
144
  | `diviops_section_remove` | Remove a section by admin label |
@@ -318,8 +320,11 @@ Ensure `WP_URL`, `WP_USER`, and `WP_APP_PASSWORD` are all set. Check your `claud
318
320
  - Verify the WP plugin is active: visit `{WP_URL}/wp-json/diviops/v1/schema/settings` in your browser
319
321
  - Check Application Password is correct (try with curl first)
320
322
 
321
- ### "Version mismatch" error
322
- The MCP server and WP plugin versions are incompatible. Update whichever side is older.
323
+ ### "This tool requires plugin capability" error
324
+ A specific tool failed at the per-tool capability gate (#486) because the active diviops-agent plugin doesn't advertise that capability. Update the plugin to ≥ 1.2.0 (the version that introduced the capability map). Other tools the older plugin does support keep working — the gate is per-tool, not a global floor.
325
+
326
+ ### "Server too old for plugin" error
327
+ The plugin returned HTTP 426 because this MCP server is below the plugin's `MIN_SERVER_VERSION`. Update the MCP server (`npm install -g @diviops/mcp-server@latest` or rebuild from source).
323
328
 
324
329
  ### "Permission denied" errors
325
330
  - The WP user must have `edit_posts` capability (Editor or Admin role)
@@ -1,8 +1,21 @@
1
1
  /**
2
- * Version compatibility between MCP server and WP plugin.
2
+ * Server↔plugin compatibility surface.
3
+ *
4
+ * As of #486, the global `MIN_PLUGIN_VERSION` floor is gone. Compatibility
5
+ * is now decided per-tool against a capability map returned by the plugin's
6
+ * `/handshake`. See `wp-client.ts` (handshake parse) and `index.ts`
7
+ * (`requireCapability` gate at each plugin-touching tool entry).
8
+ *
9
+ * Implicit floor: pre-1.2.0 plugins emit `capabilities` as a string[] of
10
+ * coarse namespace tags, not the per-tool map this server expects.
11
+ * `wp-client.ts` normalizes that legacy shape to an empty map, which makes
12
+ * every gated tool fail fast with the upgrade hint. So while there is no
13
+ * declared `MIN_PLUGIN_VERSION` constant, the capability-map shape change
14
+ * effectively sets a 1.2.0 floor for any tool that calls `requireCapability`.
15
+ *
16
+ * `compareVersions` is kept exported because handshake helper code and
17
+ * future per-tool soft-deprecation messages may want it.
3
18
  */
4
- /** Minimum WP plugin version this server requires. */
5
- export declare const MIN_PLUGIN_VERSION = "1.0.0";
6
19
  /**
7
20
  * Compare two semver-like version strings (supports pre-release tags).
8
21
  *
@@ -14,6 +27,15 @@ export declare const MIN_PLUGIN_VERSION = "1.0.0";
14
27
  * Pre-release versions (e.g. 1.0.0-beta.22) sort before their release (1.0.0).
15
28
  */
16
29
  export declare function compareVersions(a: string, b: string): -1 | 0 | 1;
30
+ /**
31
+ * Shape returned by `POST /diviops/v1/handshake`.
32
+ *
33
+ * `capabilities` is a per-tool map keyed by post-rename tool slug
34
+ * (without the `diviops_` prefix). Older plugins (pre-1.2.0) emit
35
+ * a string[] of coarse namespace keys; the server normalizes that
36
+ * legacy shape to an empty map (every gated tool then fails fast
37
+ * with an upgrade hint, which is the intended behavior).
38
+ */
17
39
  export interface HandshakeResult {
18
40
  compatible: boolean;
19
41
  plugin_version: string;
@@ -22,5 +44,16 @@ export interface HandshakeResult {
22
44
  active: boolean;
23
45
  version: string | null;
24
46
  };
25
- capabilities: string[];
47
+ capabilities: Record<string, boolean>;
48
+ }
49
+ /**
50
+ * Thrown when a tool handler calls `requireCapability(key)` and the
51
+ * plugin's handshake response did not include `key`. The server's
52
+ * tool dispatch wraps this into the MCP error response, surfacing
53
+ * the upgrade hint to the agent.
54
+ */
55
+ export declare class MissingCapabilityError extends Error {
56
+ readonly capability: string;
57
+ readonly pluginVersion: string;
58
+ constructor(capability: string, pluginVersion: string);
26
59
  }
@@ -1,8 +1,21 @@
1
1
  /**
2
- * Version compatibility between MCP server and WP plugin.
2
+ * Server↔plugin compatibility surface.
3
+ *
4
+ * As of #486, the global `MIN_PLUGIN_VERSION` floor is gone. Compatibility
5
+ * is now decided per-tool against a capability map returned by the plugin's
6
+ * `/handshake`. See `wp-client.ts` (handshake parse) and `index.ts`
7
+ * (`requireCapability` gate at each plugin-touching tool entry).
8
+ *
9
+ * Implicit floor: pre-1.2.0 plugins emit `capabilities` as a string[] of
10
+ * coarse namespace tags, not the per-tool map this server expects.
11
+ * `wp-client.ts` normalizes that legacy shape to an empty map, which makes
12
+ * every gated tool fail fast with the upgrade hint. So while there is no
13
+ * declared `MIN_PLUGIN_VERSION` constant, the capability-map shape change
14
+ * effectively sets a 1.2.0 floor for any tool that calls `requireCapability`.
15
+ *
16
+ * `compareVersions` is kept exported because handshake helper code and
17
+ * future per-tool soft-deprecation messages may want it.
3
18
  */
4
- /** Minimum WP plugin version this server requires. */
5
- export const MIN_PLUGIN_VERSION = '1.0.0';
6
19
  /**
7
20
  * Compare two semver-like version strings (supports pre-release tags).
8
21
  *
@@ -66,3 +79,20 @@ export function compareVersions(a, b) {
66
79
  }
67
80
  return 0;
68
81
  }
82
+ /**
83
+ * Thrown when a tool handler calls `requireCapability(key)` and the
84
+ * plugin's handshake response did not include `key`. The server's
85
+ * tool dispatch wraps this into the MCP error response, surfacing
86
+ * the upgrade hint to the agent.
87
+ */
88
+ export class MissingCapabilityError extends Error {
89
+ capability;
90
+ pluginVersion;
91
+ constructor(capability, pluginVersion) {
92
+ super(`Tool requires plugin capability "${capability}", which is not present in the active diviops-agent plugin (version ${pluginVersion}). ` +
93
+ 'Upgrade the diviops-agent plugin to the version shipped alongside this MCP server release.');
94
+ this.capability = capability;
95
+ this.pluginVersion = pluginVersion;
96
+ this.name = 'MissingCapabilityError';
97
+ }
98
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
12
12
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
13
13
  import { z } from "zod";
14
14
  import { WPClient } from "./wp-client.js";
15
+ import { MissingCapabilityError } from "./compatibility.js";
15
16
  import { optimizeSchema } from "./schema-optimizer.js";
16
17
  import { createWpCli } from "./wp-cli.js";
17
18
  import { findForeignVarRefs, scanAttrsForForeignVarRefs, isolationErrorResult, } from "./validate-attrs.js";
@@ -82,8 +83,49 @@ const server = new McpServer({
82
83
  name: "diviops-mcp",
83
84
  version: SERVER_VERSION,
84
85
  });
86
+ let handshakeState = { kind: "pending" };
87
+ function requireCapability(key) {
88
+ // Only gate when we have a real capability map. On handshake failure,
89
+ // bypass the gate so the underlying request surfaces the actual cause
90
+ // (auth, network, 5xx) rather than misattributing it to the plugin
91
+ // version.
92
+ if (handshakeState.kind !== "ok")
93
+ return;
94
+ if (!handshakeState.capabilities[key]) {
95
+ throw new MissingCapabilityError(key, handshakeState.pluginVersion);
96
+ }
97
+ }
98
+ // `any` here is deliberate, not laziness. McpServer.registerTool is a
99
+ // multi-overload generic whose `cb`/`InputArgs` machinery doesn't compose
100
+ // with `Parameters<typeof server.registerTool>` (overload collapse to
101
+ // `never`). Restating its Zod-driven generics in this thin wrapper buys
102
+ // no real safety — the per-callsite `inputSchema` Zod object at every
103
+ // usage site below is what enforces actual argument shape; this helper
104
+ // only adds a capability-check + an error envelope on top, both shape-
105
+ // independent. Scope: 4 narrow suppressions, all in this 25-line block.
106
+ /* eslint-disable @typescript-eslint/no-explicit-any */
107
+ function registerPluginTool(name, config, handler) {
108
+ const key = name.replace(/^diviops_/, "");
109
+ const wrapped = (async (args) => {
110
+ try {
111
+ requireCapability(key);
112
+ }
113
+ catch (e) {
114
+ if (e instanceof MissingCapabilityError) {
115
+ return {
116
+ content: [{ type: "text", text: e.message }],
117
+ isError: true,
118
+ };
119
+ }
120
+ throw e;
121
+ }
122
+ return handler(args);
123
+ });
124
+ server.registerTool(name, config, wrapped);
125
+ }
126
+ /* eslint-enable @typescript-eslint/no-explicit-any */
85
127
  // ── Read Tools ───────────────────────────────────────────────────────
86
- server.registerTool("diviops_page_list", {
128
+ registerPluginTool("diviops_page_list", {
87
129
  description: "List pages/posts in the WordPress site. Returns title, ID, URL, status, and whether each page uses Divi builder.",
88
130
  inputSchema: {
89
131
  post_type: z
@@ -112,7 +154,7 @@ server.registerTool("diviops_page_list", {
112
154
  ],
113
155
  };
114
156
  });
115
- server.registerTool("diviops_page_get", {
157
+ registerPluginTool("diviops_page_get", {
116
158
  description: "Get detailed info about a specific page including its raw Divi block content.",
117
159
  inputSchema: {
118
160
  page_id: z.number().describe("WordPress post/page ID"),
@@ -125,7 +167,7 @@ server.registerTool("diviops_page_get", {
125
167
  ],
126
168
  };
127
169
  });
128
- server.registerTool("diviops_page_get_layout", {
170
+ registerPluginTool("diviops_page_get_layout", {
129
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).",
130
172
  inputSchema: {
131
173
  page_id: z.number().describe("WordPress post/page ID"),
@@ -145,7 +187,7 @@ server.registerTool("diviops_page_get_layout", {
145
187
  ],
146
188
  };
147
189
  });
148
- server.registerTool("diviops_schema_list_modules", {
190
+ registerPluginTool("diviops_schema_list_modules", {
149
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.",
150
192
  }, async () => {
151
193
  const result = await wp.request("/schema/modules");
@@ -155,7 +197,7 @@ server.registerTool("diviops_schema_list_modules", {
155
197
  ],
156
198
  };
157
199
  });
158
- server.registerTool("diviops_schema_get_module", {
200
+ registerPluginTool("diviops_schema_get_module", {
159
201
  description: "Get the attribute schema for a Divi module. Returns optimized schema by default (~70% smaller) with content-relevant fields only. Use raw: true for the full schema including CSS selectors and VB metadata.",
160
202
  inputSchema: {
161
203
  module_name: z
@@ -176,7 +218,7 @@ server.registerTool("diviops_schema_get_module", {
176
218
  ],
177
219
  };
178
220
  });
179
- server.registerTool("diviops_schema_get_settings", {
221
+ registerPluginTool("diviops_schema_get_settings", {
180
222
  description: "Get Divi site settings including theme options, site info, and builder version. Useful for understanding the site context before generating content.",
181
223
  }, async () => {
182
224
  const result = await wp.request("/schema/settings");
@@ -186,7 +228,7 @@ server.registerTool("diviops_schema_get_settings", {
186
228
  ],
187
229
  };
188
230
  });
189
- server.registerTool("diviops_global_color_list", {
231
+ registerPluginTool("diviops_global_color_list", {
190
232
  description: "Get the global color palette defined in Divi. Returns all global colors that can be referenced by modules.",
191
233
  }, async () => {
192
234
  const result = await wp.request("/global-color/list");
@@ -196,7 +238,7 @@ server.registerTool("diviops_global_color_list", {
196
238
  ],
197
239
  };
198
240
  });
199
- server.registerTool("diviops_global_color_create", {
241
+ registerPluginTool("diviops_global_color_create", {
200
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.",
201
243
  inputSchema: {
202
244
  color: z
@@ -230,7 +272,7 @@ server.registerTool("diviops_global_color_create", {
230
272
  });
231
273
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
232
274
  });
233
- server.registerTool("diviops_global_color_update", {
275
+ registerPluginTool("diviops_global_color_update", {
234
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.",
235
277
  inputSchema: {
236
278
  gcid: z
@@ -269,7 +311,7 @@ server.registerTool("diviops_global_color_update", {
269
311
  });
270
312
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
271
313
  });
272
- server.registerTool("diviops_global_color_delete", {
314
+ registerPluginTool("diviops_global_color_delete", {
273
315
  description: "Delete a global color from the registry by gcid. Refuses by default if the color is tracked as referenced by any post (per Divi's `usedInPosts` index — pass `force: true` to delete anyway; orphan refs will render as invalid CSS until pages are re-saved through VB). Always refuses to delete the 5 customizer-bound defaults (gcid-primary-color, gcid-secondary-color, gcid-heading-color, gcid-body-color, gcid-link-color) regardless of force — those must be edited via WP Customizer. CONCURRENCY: same VB-session race caveat as diviops_global_color_create — an active VB session's next save can re-introduce a color we just deleted if the session held stale data.",
274
316
  inputSchema: {
275
317
  gcid: z
@@ -291,7 +333,7 @@ server.registerTool("diviops_global_color_delete", {
291
333
  });
292
334
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
293
335
  });
294
- server.registerTool("diviops_global_font_list", {
336
+ registerPluginTool("diviops_global_font_list", {
295
337
  description: "Get the global font definitions from Divi settings.",
296
338
  }, async () => {
297
339
  const result = await wp.request("/global-font/list");
@@ -301,7 +343,7 @@ server.registerTool("diviops_global_font_list", {
301
343
  ],
302
344
  };
303
345
  });
304
- server.registerTool("diviops_meta_find_icon", {
346
+ registerPluginTool("diviops_meta_find_icon", {
305
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.",
306
348
  inputSchema: {
307
349
  query: z
@@ -327,7 +369,7 @@ server.registerTool("diviops_meta_find_icon", {
327
369
  };
328
370
  });
329
371
  // ── Write Tools ──────────────────────────────────────────────────────
330
- server.registerTool("diviops_page_update_content", {
372
+ registerPluginTool("diviops_page_update_content", {
331
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.",
332
374
  inputSchema: {
333
375
  page_id: z.number().describe("WordPress post/page ID to update"),
@@ -349,7 +391,7 @@ server.registerTool("diviops_page_update_content", {
349
391
  ],
350
392
  };
351
393
  });
352
- server.registerTool("diviops_render_preview", {
394
+ registerPluginTool("diviops_render_preview", {
353
395
  description: "Render Divi block markup to HTML. Use this to preview what the output will look like before saving. Useful for validation.",
354
396
  inputSchema: {
355
397
  content: z.string().describe("Divi block markup to render to HTML"),
@@ -365,7 +407,7 @@ server.registerTool("diviops_render_preview", {
365
407
  ],
366
408
  };
367
409
  });
368
- server.registerTool("diviops_validate_blocks", {
410
+ registerPluginTool("diviops_validate_blocks", {
369
411
  description: "Validate Divi block markup before saving. Checks structure (malformed comments, unknown blocks, missing builderVersion), required attributes (layout display on containers), and known pitfalls (button padding path, icon.enable, gradient enabled/positions). Returns errors and warnings.",
370
412
  inputSchema: {
371
413
  content: z.string().describe("Divi block markup to validate"),
@@ -381,7 +423,7 @@ server.registerTool("diviops_validate_blocks", {
381
423
  ],
382
424
  };
383
425
  });
384
- server.registerTool("diviops_section_append", {
426
+ registerPluginTool("diviops_section_append", {
385
427
  description: "Append a Divi section to an existing page without overwriting other content. Use this to incrementally build pages.",
386
428
  inputSchema: {
387
429
  page_id: z.number().describe("WordPress post/page ID"),
@@ -408,7 +450,7 @@ server.registerTool("diviops_section_append", {
408
450
  ],
409
451
  };
410
452
  });
411
- server.registerTool("diviops_section_replace", {
453
+ registerPluginTool("diviops_section_replace", {
412
454
  description: "Replace a section on a page. Target by admin label OR text content. Use occurrence when multiple sections match.",
413
455
  inputSchema: {
414
456
  page_id: z.number().describe("WordPress post/page ID"),
@@ -450,7 +492,7 @@ server.registerTool("diviops_section_replace", {
450
492
  ],
451
493
  };
452
494
  });
453
- server.registerTool("diviops_section_remove", {
495
+ registerPluginTool("diviops_section_remove", {
454
496
  description: "Remove a section from a page. Target by admin label OR text content. Use occurrence when multiple sections match.",
455
497
  inputSchema: {
456
498
  page_id: z.number().describe("WordPress post/page ID"),
@@ -486,7 +528,7 @@ server.registerTool("diviops_section_remove", {
486
528
  ],
487
529
  };
488
530
  });
489
- server.registerTool("diviops_section_get", {
531
+ registerPluginTool("diviops_section_get", {
490
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.",
491
533
  inputSchema: {
492
534
  page_id: z.number().describe("WordPress post/page ID"),
@@ -520,7 +562,7 @@ server.registerTool("diviops_section_get", {
520
562
  ],
521
563
  };
522
564
  });
523
- server.registerTool("diviops_module_update", {
565
+ registerPluginTool("diviops_module_update", {
524
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.',
525
567
  inputSchema: {
526
568
  page_id: z.number().describe("WordPress post/page ID"),
@@ -570,7 +612,7 @@ server.registerTool("diviops_module_update", {
570
612
  ],
571
613
  };
572
614
  });
573
- server.registerTool("diviops_module_move", {
615
+ registerPluginTool("diviops_module_move", {
574
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.',
575
617
  inputSchema: {
576
618
  page_id: z.number().describe("WordPress post/page ID"),
@@ -644,7 +686,7 @@ server.registerTool("diviops_module_move", {
644
686
  ],
645
687
  };
646
688
  });
647
- server.registerTool("diviops_module_lock", {
689
+ registerPluginTool("diviops_module_lock", {
648
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.',
649
691
  inputSchema: {
650
692
  page_id: z.number().describe("WordPress post/page ID"),
@@ -666,7 +708,7 @@ server.registerTool("diviops_module_lock", {
666
708
  const result = await wp.request(`/module/lock/${page_id}`, { method: "POST", body });
667
709
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
668
710
  });
669
- server.registerTool("diviops_module_unlock", {
711
+ registerPluginTool("diviops_module_unlock", {
670
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.",
671
713
  inputSchema: {
672
714
  page_id: z.number().describe("WordPress post/page ID"),
@@ -688,7 +730,7 @@ server.registerTool("diviops_module_unlock", {
688
730
  const result = await wp.request(`/module/unlock/${page_id}`, { method: "POST", body });
689
731
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
690
732
  });
691
- server.registerTool("diviops_module_clone", {
733
+ registerPluginTool("diviops_module_clone", {
692
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.',
693
735
  inputSchema: {
694
736
  page_id: z.number().describe("WordPress post/page ID"),
@@ -713,7 +755,7 @@ server.registerTool("diviops_module_clone", {
713
755
  const result = await wp.request(`/module/clone/${page_id}`, { method: "POST", body });
714
756
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
715
757
  });
716
- server.registerTool("diviops_page_create", {
758
+ registerPluginTool("diviops_page_create", {
717
759
  description: "Create a new WordPress page, optionally with Divi block content.",
718
760
  inputSchema: {
719
761
  title: z.string().describe("Page title"),
@@ -744,8 +786,71 @@ server.registerTool("diviops_page_create", {
744
786
  ],
745
787
  };
746
788
  });
789
+ registerPluginTool("diviops_page_trash", {
790
+ description: "Trash or permanently delete a page/post. Defaults to trash (reversible via WP Admin → Trash). Pass force=true to permanently delete (wp_delete_post — irreversible). Idempotent: trashing an already-trashed post is a no-op. Pass dry_run=true to preview without mutating. Replaces wp-cli `post delete --force=0|1` routing for AI-agent callers (typed input, deterministic envelope).",
791
+ inputSchema: {
792
+ post_id: z.number().int().describe("WordPress post/page ID"),
793
+ force: z
794
+ .boolean()
795
+ .optional()
796
+ .default(false)
797
+ .describe("When true, permanently delete (skips trash). Default false moves to trash."),
798
+ dry_run: z
799
+ .boolean()
800
+ .optional()
801
+ .default(false)
802
+ .describe("When true, return the change plan without mutating state."),
803
+ },
804
+ }, async ({ post_id, force, dry_run }) => {
805
+ const result = await wp.request(`/page/trash/${post_id}`, {
806
+ method: "POST",
807
+ body: {
808
+ force: force ?? false,
809
+ dry_run: dry_run ?? false,
810
+ },
811
+ });
812
+ return {
813
+ content: [
814
+ { type: "text", text: JSON.stringify(result) },
815
+ ],
816
+ };
817
+ });
818
+ registerPluginTool("diviops_page_update_status", {
819
+ description: "Update a page's post_status. Valid statuses: publish, draft, private, pending, future. status='future' requires date_gmt (ISO 8601 UTC, must be in the future) — server writes both post_date_gmt and the site-tz post_date so WP's scheduler picks it up. status='publish' on a previously-scheduled post clears the future date so it publishes immediately. Idempotent: same-status update is a no-op. Pass dry_run=true to preview. Replaces wp-cli `post update --post_status=...` routing.",
820
+ inputSchema: {
821
+ post_id: z.number().int().describe("WordPress post/page ID"),
822
+ status: z
823
+ .enum(["publish", "draft", "private", "pending", "future"])
824
+ .describe("Target post status"),
825
+ date_gmt: z
826
+ .string()
827
+ .optional()
828
+ .describe("Required when status='future'. ISO 8601 UTC datetime (e.g. '2026-06-01T09:00:00Z'). Must be in the future."),
829
+ dry_run: z
830
+ .boolean()
831
+ .optional()
832
+ .default(false)
833
+ .describe("When true, return the change plan without mutating state."),
834
+ },
835
+ }, async ({ post_id, status, date_gmt, dry_run }) => {
836
+ const body = {
837
+ status,
838
+ dry_run: dry_run ?? false,
839
+ };
840
+ if (date_gmt)
841
+ body.date_gmt = date_gmt;
842
+ const result = await wp.request(`/page/update-status/${post_id}`, {
843
+ method: "POST",
844
+ body,
845
+ });
846
+ return {
847
+ content: [
848
+ { type: "text", text: JSON.stringify(result) },
849
+ ],
850
+ };
851
+ });
747
852
  // ── Preset Tools ────────────────────────────────────────────────────
748
- server.registerTool("diviops_preset_audit", {
853
+ registerPluginTool("diviops_preset_audit", {
749
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.",
750
855
  }, async () => {
751
856
  const result = await wp.request("/preset/audit");
@@ -755,7 +860,7 @@ server.registerTool("diviops_preset_audit", {
755
860
  ],
756
861
  };
757
862
  });
758
- server.registerTool("diviops_preset_cleanup", {
863
+ registerPluginTool("diviops_preset_cleanup", {
759
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.',
760
865
  inputSchema: {
761
866
  dry_run: z
@@ -801,7 +906,7 @@ server.registerTool("diviops_preset_cleanup", {
801
906
  ],
802
907
  };
803
908
  });
804
- server.registerTool("diviops_preset_update", {
909
+ registerPluginTool("diviops_preset_update", {
805
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.",
806
911
  inputSchema: {
807
912
  preset_id: z.string().describe("Preset ID (UUID or short ID)"),
@@ -834,7 +939,7 @@ server.registerTool("diviops_preset_update", {
834
939
  ],
835
940
  };
836
941
  });
837
- server.registerTool("diviops_preset_delete", {
942
+ registerPluginTool("diviops_preset_delete", {
838
943
  description: "Delete a specific preset by ID. Use diviops_preset_audit first to verify the preset is unreferenced before deleting. Refuses with 409 preset_is_default if the target is the registered default for its module/group bucket — clear the pointer first via diviops_preset_set_default with unset=true, or pass force=true to delete and clear the pointer in one write.",
839
944
  inputSchema: {
840
945
  preset_id: z.string().describe("Preset ID to delete"),
@@ -857,7 +962,7 @@ server.registerTool("diviops_preset_delete", {
857
962
  ],
858
963
  };
859
964
  });
860
- server.registerTool("diviops_preset_create", {
965
+ registerPluginTool("diviops_preset_create", {
861
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.',
862
967
  inputSchema: {
863
968
  module_name: z
@@ -916,7 +1021,7 @@ server.registerTool("diviops_preset_create", {
916
1021
  ],
917
1022
  };
918
1023
  });
919
- server.registerTool("diviops_preset_reassign", {
1024
+ registerPluginTool("diviops_preset_reassign", {
920
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.',
921
1026
  inputSchema: {
922
1027
  old_uuid: z
@@ -965,7 +1070,7 @@ server.registerTool("diviops_preset_reassign", {
965
1070
  ],
966
1071
  };
967
1072
  });
968
- server.registerTool("diviops_preset_scan_orphans", {
1073
+ registerPluginTool("diviops_preset_scan_orphans", {
969
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.",
970
1075
  }, async () => {
971
1076
  const result = await wp.request("/preset/scan-orphans");
@@ -975,7 +1080,7 @@ server.registerTool("diviops_preset_scan_orphans", {
975
1080
  ],
976
1081
  };
977
1082
  });
978
- server.registerTool("diviops_preset_set_default", {
1083
+ registerPluginTool("diviops_preset_set_default", {
979
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.",
980
1085
  inputSchema: {
981
1086
  preset_id: z
@@ -1016,7 +1121,7 @@ server.registerTool("diviops_preset_set_default", {
1016
1121
  };
1017
1122
  });
1018
1123
  // ── Library Tools ───────────────────────────────────────────────────
1019
- server.registerTool("diviops_library_list", {
1124
+ registerPluginTool("diviops_library_list", {
1020
1125
  description: "List saved Divi Library items. Filter by layout_type (section, row, module) and scope (global, non_global).",
1021
1126
  inputSchema: {
1022
1127
  layout_type: z
@@ -1048,7 +1153,7 @@ server.registerTool("diviops_library_list", {
1048
1153
  ],
1049
1154
  };
1050
1155
  });
1051
- server.registerTool("diviops_library_get", {
1156
+ registerPluginTool("diviops_library_get", {
1052
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.",
1053
1158
  inputSchema: {
1054
1159
  item_id: z.number().describe("Library item ID"),
@@ -1061,7 +1166,7 @@ server.registerTool("diviops_library_get", {
1061
1166
  ],
1062
1167
  };
1063
1168
  });
1064
- server.registerTool("diviops_library_save", {
1169
+ registerPluginTool("diviops_library_save", {
1065
1170
  description: 'Save Divi block markup to the Divi Library for reuse. Saved items appear in the VB\'s "Add From Library" panel.',
1066
1171
  inputSchema: {
1067
1172
  title: z.string().describe("Display name for the library item"),
@@ -1096,7 +1201,7 @@ server.registerTool("diviops_library_save", {
1096
1201
  };
1097
1202
  });
1098
1203
  // ── Theme Builder Tools ─────────────────────────────────────────────
1099
- server.registerTool("diviops_tb_template_list", {
1204
+ registerPluginTool("diviops_tb_template_list", {
1100
1205
  description: "List all Theme Builder templates with their conditions, layout IDs, and enabled status. Shows which template applies to which pages/post types.",
1101
1206
  inputSchema: {
1102
1207
  per_page: z
@@ -1120,7 +1225,7 @@ server.registerTool("diviops_tb_template_list", {
1120
1225
  ],
1121
1226
  };
1122
1227
  });
1123
- server.registerTool("diviops_tb_layout_get", {
1228
+ registerPluginTool("diviops_tb_layout_get", {
1124
1229
  description: "Get a Theme Builder layout's block markup content (header, body, or footer). Use the layout IDs from diviops_tb_template_list.",
1125
1230
  inputSchema: {
1126
1231
  layout_id: z
@@ -1135,7 +1240,7 @@ server.registerTool("diviops_tb_layout_get", {
1135
1240
  ],
1136
1241
  };
1137
1242
  });
1138
- server.registerTool("diviops_tb_layout_update", {
1243
+ registerPluginTool("diviops_tb_layout_update", {
1139
1244
  description: "Update a Theme Builder layout's block markup (header, body, or footer). Replaces the full content.",
1140
1245
  inputSchema: {
1141
1246
  layout_id: z.number().describe("Layout post ID to update"),
@@ -1152,7 +1257,7 @@ server.registerTool("diviops_tb_layout_update", {
1152
1257
  ],
1153
1258
  };
1154
1259
  });
1155
- server.registerTool("diviops_tb_template_create", {
1260
+ registerPluginTool("diviops_tb_template_create", {
1156
1261
  description: "Create a Theme Builder template with custom header and/or footer. Automatically creates layout posts, sets conditions, and links to Theme Builder.",
1157
1262
  inputSchema: {
1158
1263
  title: z.string().describe('Template name (e.g. "Landing Pages")'),
@@ -1182,7 +1287,7 @@ server.registerTool("diviops_tb_template_create", {
1182
1287
  };
1183
1288
  });
1184
1289
  // ── Canvas Tools ────────────────────────────────────────────────────
1185
- server.registerTool("diviops_canvas_create", {
1290
+ registerPluginTool("diviops_canvas_create", {
1186
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.",
1187
1292
  inputSchema: {
1188
1293
  title: z
@@ -1226,7 +1331,7 @@ server.registerTool("diviops_canvas_create", {
1226
1331
  ],
1227
1332
  };
1228
1333
  });
1229
- server.registerTool("diviops_canvas_list", {
1334
+ registerPluginTool("diviops_canvas_list", {
1230
1335
  description: "List canvases (off-canvas workspaces). Filter by parent page or list all.",
1231
1336
  inputSchema: {
1232
1337
  parent_page_id: z
@@ -1255,7 +1360,7 @@ server.registerTool("diviops_canvas_list", {
1255
1360
  ],
1256
1361
  };
1257
1362
  });
1258
- server.registerTool("diviops_canvas_get", {
1363
+ registerPluginTool("diviops_canvas_get", {
1259
1364
  description: "Get a canvas's block content and metadata.",
1260
1365
  inputSchema: {
1261
1366
  canvas_post_id: z
@@ -1270,7 +1375,7 @@ server.registerTool("diviops_canvas_get", {
1270
1375
  ],
1271
1376
  };
1272
1377
  });
1273
- server.registerTool("diviops_canvas_update", {
1378
+ registerPluginTool("diviops_canvas_update", {
1274
1379
  description: "Update a canvas's content and/or metadata. Content replaces the entire canvas.",
1275
1380
  inputSchema: {
1276
1381
  canvas_post_id: z.number().describe("Canvas post ID"),
@@ -1305,7 +1410,7 @@ server.registerTool("diviops_canvas_update", {
1305
1410
  ],
1306
1411
  };
1307
1412
  });
1308
- server.registerTool("diviops_canvas_delete", {
1413
+ registerPluginTool("diviops_canvas_delete", {
1309
1414
  description: "Delete a canvas. This permanently removes the canvas post.",
1310
1415
  inputSchema: {
1311
1416
  canvas_post_id: z.number().describe("Canvas post ID to delete"),
@@ -1788,7 +1893,7 @@ server.registerTool("diviops_template_get", {
1788
1893
  };
1789
1894
  });
1790
1895
  // ── Variable Manager CRUD ─────────────────────────────────────────────
1791
- server.registerTool("diviops_variable_list", {
1896
+ registerPluginTool("diviops_variable_list", {
1792
1897
  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.",
1793
1898
  inputSchema: {
1794
1899
  type: z
@@ -1813,7 +1918,7 @@ server.registerTool("diviops_variable_list", {
1813
1918
  ],
1814
1919
  };
1815
1920
  });
1816
- server.registerTool("diviops_variable_create", {
1921
+ registerPluginTool("diviops_variable_create", {
1817
1922
  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.',
1818
1923
  inputSchema: {
1819
1924
  type: z
@@ -1881,7 +1986,7 @@ server.registerTool("diviops_variable_create", {
1881
1986
  ],
1882
1987
  };
1883
1988
  });
1884
- server.registerTool("diviops_variable_create_fluid_system", {
1989
+ registerPluginTool("diviops_variable_create_fluid_system", {
1885
1990
  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.",
1886
1991
  inputSchema: {
1887
1992
  profile: z
@@ -2049,7 +2154,7 @@ server.registerTool("diviops_variable_create_fluid_system", {
2049
2154
  ],
2050
2155
  };
2051
2156
  });
2052
- server.registerTool("diviops_variable_delete", {
2157
+ registerPluginTool("diviops_variable_delete", {
2053
2158
  description: "Delete a design token variable by ID. Auto-detects storage from ID prefix (gcid-* = colors, gvid-* = numbers/strings/etc). Returns HTTP 409 when live references exist unless force=true — run diviops_variable_scan_orphans to see where the references live. Returns HTTP 403 for Divi's customizer-bound defaults (gcid-primary-color, gcid-secondary-color, gcid-heading-color, gcid-body-color, gcid-link-color); those are managed via WP Customizer theme options and can't be deleted via this tool.",
2054
2159
  inputSchema: {
2055
2160
  id: z
@@ -2072,7 +2177,7 @@ server.registerTool("diviops_variable_delete", {
2072
2177
  ],
2073
2178
  };
2074
2179
  });
2075
- server.registerTool("diviops_variable_scan_orphans", {
2180
+ registerPluginTool("diviops_variable_scan_orphans", {
2076
2181
  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.",
2077
2182
  }, async () => {
2078
2183
  const result = await wp.request("/variable/scan-orphans");
@@ -2082,7 +2187,7 @@ server.registerTool("diviops_variable_scan_orphans", {
2082
2187
  ],
2083
2188
  };
2084
2189
  });
2085
- server.registerTool("diviops_variable_used_on_page", {
2190
+ registerPluginTool("diviops_variable_used_on_page", {
2086
2191
  description: "Detect which numeric/font variable IDs a single page actually emits — the exact set Divi 5.4.0+ uses to scope selective `:root{--gvid-*}` CSS variable emission. Walks the same content stack the frontend assembles: post_content + active Theme Builder header/body/footer template content + appended canvas content (interaction targets etc.), plus presets referenced by that content. NOTE: this is `gvid-*` only — color variables (`gcid-*`) are emitted via a separate path (`GlobalData` color block) that is NOT scoped per-page in 5.4.0; this tool returns gvid IDs only. Use for per-page orphan validation (complements global diviops_variable_scan_orphans), preflight before bulk variable rename (know which pages are affected), or to debug why a numeric/font variable doesn't render on a specific page. Read-only. Returns variable_ids (sorted, deduped), count, and the tb_template_ids resolved for that post.",
2087
2192
  inputSchema: {
2088
2193
  post_id: z
@@ -2099,7 +2204,7 @@ server.registerTool("diviops_variable_used_on_page", {
2099
2204
  ],
2100
2205
  };
2101
2206
  });
2102
- server.registerTool("diviops_meta_flush_cache", {
2207
+ registerPluginTool("diviops_meta_flush_cache", {
2103
2208
  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.",
2104
2209
  inputSchema: {
2105
2210
  post_id: z
@@ -2140,24 +2245,38 @@ server.registerTool("diviops_meta_flush_cache", {
2140
2245
  });
2141
2246
  // ── Start ────────────────────────────────────────────────────────────
2142
2247
  async function main() {
2143
- // Version handshake — verify plugin compatibility before accepting tool calls.
2248
+ // Capability handshake — populate the per-tool gate map (#486).
2144
2249
  try {
2145
2250
  const hs = await wp.handshake(SERVER_VERSION);
2251
+ handshakeState = {
2252
+ kind: "ok",
2253
+ capabilities: hs.capabilities,
2254
+ pluginVersion: hs.plugin_version,
2255
+ };
2146
2256
  const diviInfo = hs.divi.active
2147
2257
  ? `Divi ${hs.divi.version ?? "unknown"}`
2148
2258
  : "Divi not active";
2149
- console.error(`Handshake OK: plugin ${hs.plugin_version}, ${diviInfo}, ${hs.capabilities.length} capabilities`);
2259
+ const capCount = Object.keys(hs.capabilities).filter((k) => hs.capabilities[k]).length;
2260
+ console.error(`Handshake OK: plugin ${hs.plugin_version}, ${diviInfo}, ${capCount} capabilities`);
2261
+ if (capCount === 0) {
2262
+ console.error("Warning: plugin returned an empty capability map. Plugin-touching tools will fail with an upgrade hint. Update diviops-agent to ≥1.2.0.");
2263
+ }
2150
2264
  }
2151
2265
  catch (error) {
2152
2266
  const msg = error instanceof Error ? error.message : String(error);
2153
- // Version mismatch fatal (HTTP 426 from plugin, or client-side minimum check).
2154
- if (msg.includes("WordPress API error (426)") ||
2155
- msg.includes("below the minimum required")) {
2156
- console.error(`Version mismatch: ${msg}`);
2267
+ // Plugin rejected this server as too old (HTTP 426) fatal.
2268
+ if (msg.includes("WordPress API error (426)")) {
2269
+ console.error(`Server too old for plugin: ${msg}`);
2157
2270
  process.exit(1);
2158
2271
  }
2159
- // Other errors (network, auth) warn but continue, tools will fail individually.
2160
- console.error(`Handshake warning: ${msg}`);
2272
+ // Network / auth / other transient failure mark the gate as
2273
+ // failed so plugin-touching tools fall through to their own
2274
+ // wp.request() calls and surface the real error (401, 5xx, etc.)
2275
+ // instead of being misreported as missing capabilities.
2276
+ // Codex review on PR #525: pre-#486 behavior surfaced the actual
2277
+ // cause; the gate must preserve that.
2278
+ handshakeState = { kind: "failed" };
2279
+ console.error(`Handshake warning (gate disabled): ${msg}`);
2161
2280
  }
2162
2281
  const transport = new StdioServerTransport();
2163
2282
  await server.connect(transport);
@@ -30,12 +30,17 @@ export declare class WPClient {
30
30
  message: string;
31
31
  }>;
32
32
  /**
33
- * Perform version handshake with the WP plugin.
33
+ * Perform a capability handshake with the WP plugin.
34
34
  *
35
- * Verifies that the plugin version is compatible with this server.
36
- * Throws on:
37
- * - Network errors or any non-2xx HTTP response (401/403/426/503).
38
- * - Plugin version below {@link MIN_PLUGIN_VERSION}.
35
+ * As of #486 there is no global plugin-version floor compatibility is
36
+ * decided per-tool against `result.capabilities`. This method only:
37
+ * - issues the request (network/auth errors propagate)
38
+ * - normalizes the legacy pre-1.2.0 shape (`capabilities: string[]`)
39
+ * into the post-1.2.0 shape (`capabilities: Record<string,boolean>`)
40
+ * so the rest of the server can assume a uniform map.
41
+ *
42
+ * The plugin still rejects servers below its own MIN_SERVER_VERSION
43
+ * with HTTP 426 — that error surfaces here as a regular request error.
39
44
  */
40
45
  handshake(serverVersion: string): Promise<HandshakeResult>;
41
46
  }
package/dist/wp-client.js CHANGED
@@ -4,7 +4,6 @@
4
4
  * Uses WP Application Passwords (built into WP 5.6+) for auth.
5
5
  * Generate one at: WP Admin → Users → Your Profile → Application Passwords.
6
6
  */
7
- import { MIN_PLUGIN_VERSION, compareVersions, } from './compatibility.js';
8
7
  /**
9
8
  * Normalize quote-escape pathologies inside `$variable(...)$` token regions only.
10
9
  *
@@ -161,22 +160,33 @@ export class WPClient {
161
160
  }
162
161
  }
163
162
  /**
164
- * Perform version handshake with the WP plugin.
163
+ * Perform a capability handshake with the WP plugin.
165
164
  *
166
- * Verifies that the plugin version is compatible with this server.
167
- * Throws on:
168
- * - Network errors or any non-2xx HTTP response (401/403/426/503).
169
- * - Plugin version below {@link MIN_PLUGIN_VERSION}.
165
+ * As of #486 there is no global plugin-version floor compatibility is
166
+ * decided per-tool against `result.capabilities`. This method only:
167
+ * - issues the request (network/auth errors propagate)
168
+ * - normalizes the legacy pre-1.2.0 shape (`capabilities: string[]`)
169
+ * into the post-1.2.0 shape (`capabilities: Record<string,boolean>`)
170
+ * so the rest of the server can assume a uniform map.
171
+ *
172
+ * The plugin still rejects servers below its own MIN_SERVER_VERSION
173
+ * with HTTP 426 — that error surfaces here as a regular request error.
170
174
  */
171
175
  async handshake(serverVersion) {
172
176
  const result = await this.request('/handshake', {
173
177
  method: 'POST',
174
178
  body: { mcp_server_version: serverVersion },
175
179
  });
176
- // Server-side check passed now verify plugin meets our minimum.
177
- if (compareVersions(result.plugin_version, MIN_PLUGIN_VERSION) < 0) {
178
- throw new Error(`WP plugin version ${result.plugin_version} is below the minimum required ${MIN_PLUGIN_VERSION}. ` +
179
- 'Please update the diviops-agent plugin.');
180
+ // Pre-1.2.0 plugins emit `capabilities` as a string[] of coarse
181
+ // namespace tags. Coerce to an empty map so per-tool gates fail fast
182
+ // with the upgrade hint instead of silently passing because of a
183
+ // shape mismatch.
184
+ if (Array.isArray(result.capabilities)) {
185
+ result.capabilities = {};
186
+ }
187
+ else if (result.capabilities === null ||
188
+ typeof result.capabilities !== 'object') {
189
+ result.capabilities = {};
180
190
  }
181
191
  return result;
182
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diviops/mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server exposing Divi 5 Visual Builder as tools for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",