@diviops/mcp-server 1.1.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
@@ -320,8 +320,11 @@ Ensure `WP_URL`, `WP_USER`, and `WP_APP_PASSWORD` are all set. Check your `claud
320
320
  - Verify the WP plugin is active: visit `{WP_URL}/wp-json/diviops/v1/schema/settings` in your browser
321
321
  - Check Application Password is correct (try with curl first)
322
322
 
323
- ### "Version mismatch" error
324
- 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).
325
328
 
326
329
  ### "Permission denied" errors
327
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.1.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.1.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.1.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,7 +786,7 @@ server.registerTool("diviops_page_create", {
744
786
  ],
745
787
  };
746
788
  });
747
- server.registerTool("diviops_page_trash", {
789
+ registerPluginTool("diviops_page_trash", {
748
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).",
749
791
  inputSchema: {
750
792
  post_id: z.number().int().describe("WordPress post/page ID"),
@@ -773,7 +815,7 @@ server.registerTool("diviops_page_trash", {
773
815
  ],
774
816
  };
775
817
  });
776
- server.registerTool("diviops_page_update_status", {
818
+ registerPluginTool("diviops_page_update_status", {
777
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.",
778
820
  inputSchema: {
779
821
  post_id: z.number().int().describe("WordPress post/page ID"),
@@ -808,7 +850,7 @@ server.registerTool("diviops_page_update_status", {
808
850
  };
809
851
  });
810
852
  // ── Preset Tools ────────────────────────────────────────────────────
811
- server.registerTool("diviops_preset_audit", {
853
+ registerPluginTool("diviops_preset_audit", {
812
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.",
813
855
  }, async () => {
814
856
  const result = await wp.request("/preset/audit");
@@ -818,7 +860,7 @@ server.registerTool("diviops_preset_audit", {
818
860
  ],
819
861
  };
820
862
  });
821
- server.registerTool("diviops_preset_cleanup", {
863
+ registerPluginTool("diviops_preset_cleanup", {
822
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.',
823
865
  inputSchema: {
824
866
  dry_run: z
@@ -864,7 +906,7 @@ server.registerTool("diviops_preset_cleanup", {
864
906
  ],
865
907
  };
866
908
  });
867
- server.registerTool("diviops_preset_update", {
909
+ registerPluginTool("diviops_preset_update", {
868
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.",
869
911
  inputSchema: {
870
912
  preset_id: z.string().describe("Preset ID (UUID or short ID)"),
@@ -897,7 +939,7 @@ server.registerTool("diviops_preset_update", {
897
939
  ],
898
940
  };
899
941
  });
900
- server.registerTool("diviops_preset_delete", {
942
+ registerPluginTool("diviops_preset_delete", {
901
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.",
902
944
  inputSchema: {
903
945
  preset_id: z.string().describe("Preset ID to delete"),
@@ -920,7 +962,7 @@ server.registerTool("diviops_preset_delete", {
920
962
  ],
921
963
  };
922
964
  });
923
- server.registerTool("diviops_preset_create", {
965
+ registerPluginTool("diviops_preset_create", {
924
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.',
925
967
  inputSchema: {
926
968
  module_name: z
@@ -979,7 +1021,7 @@ server.registerTool("diviops_preset_create", {
979
1021
  ],
980
1022
  };
981
1023
  });
982
- server.registerTool("diviops_preset_reassign", {
1024
+ registerPluginTool("diviops_preset_reassign", {
983
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.',
984
1026
  inputSchema: {
985
1027
  old_uuid: z
@@ -1028,7 +1070,7 @@ server.registerTool("diviops_preset_reassign", {
1028
1070
  ],
1029
1071
  };
1030
1072
  });
1031
- server.registerTool("diviops_preset_scan_orphans", {
1073
+ registerPluginTool("diviops_preset_scan_orphans", {
1032
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.",
1033
1075
  }, async () => {
1034
1076
  const result = await wp.request("/preset/scan-orphans");
@@ -1038,7 +1080,7 @@ server.registerTool("diviops_preset_scan_orphans", {
1038
1080
  ],
1039
1081
  };
1040
1082
  });
1041
- server.registerTool("diviops_preset_set_default", {
1083
+ registerPluginTool("diviops_preset_set_default", {
1042
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.",
1043
1085
  inputSchema: {
1044
1086
  preset_id: z
@@ -1079,7 +1121,7 @@ server.registerTool("diviops_preset_set_default", {
1079
1121
  };
1080
1122
  });
1081
1123
  // ── Library Tools ───────────────────────────────────────────────────
1082
- server.registerTool("diviops_library_list", {
1124
+ registerPluginTool("diviops_library_list", {
1083
1125
  description: "List saved Divi Library items. Filter by layout_type (section, row, module) and scope (global, non_global).",
1084
1126
  inputSchema: {
1085
1127
  layout_type: z
@@ -1111,7 +1153,7 @@ server.registerTool("diviops_library_list", {
1111
1153
  ],
1112
1154
  };
1113
1155
  });
1114
- server.registerTool("diviops_library_get", {
1156
+ registerPluginTool("diviops_library_get", {
1115
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.",
1116
1158
  inputSchema: {
1117
1159
  item_id: z.number().describe("Library item ID"),
@@ -1124,7 +1166,7 @@ server.registerTool("diviops_library_get", {
1124
1166
  ],
1125
1167
  };
1126
1168
  });
1127
- server.registerTool("diviops_library_save", {
1169
+ registerPluginTool("diviops_library_save", {
1128
1170
  description: 'Save Divi block markup to the Divi Library for reuse. Saved items appear in the VB\'s "Add From Library" panel.',
1129
1171
  inputSchema: {
1130
1172
  title: z.string().describe("Display name for the library item"),
@@ -1159,7 +1201,7 @@ server.registerTool("diviops_library_save", {
1159
1201
  };
1160
1202
  });
1161
1203
  // ── Theme Builder Tools ─────────────────────────────────────────────
1162
- server.registerTool("diviops_tb_template_list", {
1204
+ registerPluginTool("diviops_tb_template_list", {
1163
1205
  description: "List all Theme Builder templates with their conditions, layout IDs, and enabled status. Shows which template applies to which pages/post types.",
1164
1206
  inputSchema: {
1165
1207
  per_page: z
@@ -1183,7 +1225,7 @@ server.registerTool("diviops_tb_template_list", {
1183
1225
  ],
1184
1226
  };
1185
1227
  });
1186
- server.registerTool("diviops_tb_layout_get", {
1228
+ registerPluginTool("diviops_tb_layout_get", {
1187
1229
  description: "Get a Theme Builder layout's block markup content (header, body, or footer). Use the layout IDs from diviops_tb_template_list.",
1188
1230
  inputSchema: {
1189
1231
  layout_id: z
@@ -1198,7 +1240,7 @@ server.registerTool("diviops_tb_layout_get", {
1198
1240
  ],
1199
1241
  };
1200
1242
  });
1201
- server.registerTool("diviops_tb_layout_update", {
1243
+ registerPluginTool("diviops_tb_layout_update", {
1202
1244
  description: "Update a Theme Builder layout's block markup (header, body, or footer). Replaces the full content.",
1203
1245
  inputSchema: {
1204
1246
  layout_id: z.number().describe("Layout post ID to update"),
@@ -1215,7 +1257,7 @@ server.registerTool("diviops_tb_layout_update", {
1215
1257
  ],
1216
1258
  };
1217
1259
  });
1218
- server.registerTool("diviops_tb_template_create", {
1260
+ registerPluginTool("diviops_tb_template_create", {
1219
1261
  description: "Create a Theme Builder template with custom header and/or footer. Automatically creates layout posts, sets conditions, and links to Theme Builder.",
1220
1262
  inputSchema: {
1221
1263
  title: z.string().describe('Template name (e.g. "Landing Pages")'),
@@ -1245,7 +1287,7 @@ server.registerTool("diviops_tb_template_create", {
1245
1287
  };
1246
1288
  });
1247
1289
  // ── Canvas Tools ────────────────────────────────────────────────────
1248
- server.registerTool("diviops_canvas_create", {
1290
+ registerPluginTool("diviops_canvas_create", {
1249
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.",
1250
1292
  inputSchema: {
1251
1293
  title: z
@@ -1289,7 +1331,7 @@ server.registerTool("diviops_canvas_create", {
1289
1331
  ],
1290
1332
  };
1291
1333
  });
1292
- server.registerTool("diviops_canvas_list", {
1334
+ registerPluginTool("diviops_canvas_list", {
1293
1335
  description: "List canvases (off-canvas workspaces). Filter by parent page or list all.",
1294
1336
  inputSchema: {
1295
1337
  parent_page_id: z
@@ -1318,7 +1360,7 @@ server.registerTool("diviops_canvas_list", {
1318
1360
  ],
1319
1361
  };
1320
1362
  });
1321
- server.registerTool("diviops_canvas_get", {
1363
+ registerPluginTool("diviops_canvas_get", {
1322
1364
  description: "Get a canvas's block content and metadata.",
1323
1365
  inputSchema: {
1324
1366
  canvas_post_id: z
@@ -1333,7 +1375,7 @@ server.registerTool("diviops_canvas_get", {
1333
1375
  ],
1334
1376
  };
1335
1377
  });
1336
- server.registerTool("diviops_canvas_update", {
1378
+ registerPluginTool("diviops_canvas_update", {
1337
1379
  description: "Update a canvas's content and/or metadata. Content replaces the entire canvas.",
1338
1380
  inputSchema: {
1339
1381
  canvas_post_id: z.number().describe("Canvas post ID"),
@@ -1368,7 +1410,7 @@ server.registerTool("diviops_canvas_update", {
1368
1410
  ],
1369
1411
  };
1370
1412
  });
1371
- server.registerTool("diviops_canvas_delete", {
1413
+ registerPluginTool("diviops_canvas_delete", {
1372
1414
  description: "Delete a canvas. This permanently removes the canvas post.",
1373
1415
  inputSchema: {
1374
1416
  canvas_post_id: z.number().describe("Canvas post ID to delete"),
@@ -1851,7 +1893,7 @@ server.registerTool("diviops_template_get", {
1851
1893
  };
1852
1894
  });
1853
1895
  // ── Variable Manager CRUD ─────────────────────────────────────────────
1854
- server.registerTool("diviops_variable_list", {
1896
+ registerPluginTool("diviops_variable_list", {
1855
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.",
1856
1898
  inputSchema: {
1857
1899
  type: z
@@ -1876,7 +1918,7 @@ server.registerTool("diviops_variable_list", {
1876
1918
  ],
1877
1919
  };
1878
1920
  });
1879
- server.registerTool("diviops_variable_create", {
1921
+ registerPluginTool("diviops_variable_create", {
1880
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.',
1881
1923
  inputSchema: {
1882
1924
  type: z
@@ -1944,7 +1986,7 @@ server.registerTool("diviops_variable_create", {
1944
1986
  ],
1945
1987
  };
1946
1988
  });
1947
- server.registerTool("diviops_variable_create_fluid_system", {
1989
+ registerPluginTool("diviops_variable_create_fluid_system", {
1948
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.",
1949
1991
  inputSchema: {
1950
1992
  profile: z
@@ -2112,7 +2154,7 @@ server.registerTool("diviops_variable_create_fluid_system", {
2112
2154
  ],
2113
2155
  };
2114
2156
  });
2115
- server.registerTool("diviops_variable_delete", {
2157
+ registerPluginTool("diviops_variable_delete", {
2116
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.",
2117
2159
  inputSchema: {
2118
2160
  id: z
@@ -2135,7 +2177,7 @@ server.registerTool("diviops_variable_delete", {
2135
2177
  ],
2136
2178
  };
2137
2179
  });
2138
- server.registerTool("diviops_variable_scan_orphans", {
2180
+ registerPluginTool("diviops_variable_scan_orphans", {
2139
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.",
2140
2182
  }, async () => {
2141
2183
  const result = await wp.request("/variable/scan-orphans");
@@ -2145,7 +2187,7 @@ server.registerTool("diviops_variable_scan_orphans", {
2145
2187
  ],
2146
2188
  };
2147
2189
  });
2148
- server.registerTool("diviops_variable_used_on_page", {
2190
+ registerPluginTool("diviops_variable_used_on_page", {
2149
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.",
2150
2192
  inputSchema: {
2151
2193
  post_id: z
@@ -2162,7 +2204,7 @@ server.registerTool("diviops_variable_used_on_page", {
2162
2204
  ],
2163
2205
  };
2164
2206
  });
2165
- server.registerTool("diviops_meta_flush_cache", {
2207
+ registerPluginTool("diviops_meta_flush_cache", {
2166
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.",
2167
2209
  inputSchema: {
2168
2210
  post_id: z
@@ -2203,24 +2245,38 @@ server.registerTool("diviops_meta_flush_cache", {
2203
2245
  });
2204
2246
  // ── Start ────────────────────────────────────────────────────────────
2205
2247
  async function main() {
2206
- // Version handshake — verify plugin compatibility before accepting tool calls.
2248
+ // Capability handshake — populate the per-tool gate map (#486).
2207
2249
  try {
2208
2250
  const hs = await wp.handshake(SERVER_VERSION);
2251
+ handshakeState = {
2252
+ kind: "ok",
2253
+ capabilities: hs.capabilities,
2254
+ pluginVersion: hs.plugin_version,
2255
+ };
2209
2256
  const diviInfo = hs.divi.active
2210
2257
  ? `Divi ${hs.divi.version ?? "unknown"}`
2211
2258
  : "Divi not active";
2212
- 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
+ }
2213
2264
  }
2214
2265
  catch (error) {
2215
2266
  const msg = error instanceof Error ? error.message : String(error);
2216
- // Version mismatch fatal (HTTP 426 from plugin, or client-side minimum check).
2217
- if (msg.includes("WordPress API error (426)") ||
2218
- msg.includes("below the minimum required")) {
2219
- 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}`);
2220
2270
  process.exit(1);
2221
2271
  }
2222
- // Other errors (network, auth) warn but continue, tools will fail individually.
2223
- 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}`);
2224
2280
  }
2225
2281
  const transport = new StdioServerTransport();
2226
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.1.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",