@diviops/mcp-server 0.2.28 → 0.2.29

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,9 +96,9 @@ 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 (57)
99
+ ## Available Tools (63)
100
100
 
101
- ### Read (27)
101
+ ### Read (30)
102
102
  | Tool | Description |
103
103
  |------|-------------|
104
104
  | `diviops_test_connection` | Test WordPress connection and Divi version |
@@ -128,8 +128,11 @@ The server connects via standard WordPress REST API and works with any environme
128
128
  | `diviops_variables_used_on_page` | Detect which `gvid-` (numeric/font) IDs a single page 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 TB header/body/footer + appended canvases + presets). `gcid-` colors are out of scope (separate emission path). Use for per-page orphan validation, preflight before bulk variable rename, or to debug why a numeric/font variable doesn't render on a specific page. Read-only |
129
129
  | `diviops_list_canvases` | List all canvas pages |
130
130
  | `diviops_get_canvas` | Get canvas content |
131
+ | `diviops_scf_status` | Show SCF (Secure Custom Fields) sync status — pending JSON-vs-DB drift across field groups, post types, taxonomies, options pages. Wraps `wp scf json status` |
132
+ | `diviops_scf_list_field_groups` | 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
+ | `diviops_scf_get_field_group` | 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` |
131
134
 
132
- ### Write (28)
135
+ ### Write (31)
133
136
  | Tool | Description |
134
137
  |------|-------------|
135
138
  | `diviops_create_page` | Create a new page with optional Divi content |
@@ -160,6 +163,9 @@ The server connects via standard WordPress REST API and works with any environme
160
163
  | `diviops_create_canvas` | Create a canvas page |
161
164
  | `diviops_update_canvas` | Update canvas content |
162
165
  | `diviops_delete_canvas` | Delete a canvas page |
166
+ | `diviops_scf_export` | Export SCF schema (field groups, post types, taxonomies, options pages) as JSON to a directory under the safe-root, or to stdout. Wraps `wp scf json export` |
167
+ | `diviops_scf_import` | Import SCF schema from a JSON file (mutates DB; idempotent — existing items are updated). Wraps `wp scf json import <file>` |
168
+ | `diviops_scf_sync` | Apply pending JSON-on-disk SCF changes to the DB. Defaults to `dry_run: true` for safety. Wraps `wp scf json sync` |
163
169
 
164
170
  ### Utility (2)
165
171
  | Tool | Description |
@@ -182,7 +188,7 @@ Read-only commands plus non-destructive writes needed for core MCP functionality
182
188
  | Post meta | `post meta get`, `post meta list`, `post meta set`, `post meta update` |
183
189
  | Post types | `post-type list`, `post-type get` |
184
190
  | Taxonomies | `taxonomy list`, `term list`, `term create`, `term update` |
185
- | ACF / SCF | `acf export`, `acf import`, `acf field-group list`, `acf field-group get` |
191
+ | ACF / SCF | `acf export`, `acf import`, `acf field-group list`, `acf field-group get`, `scf json {status,sync,import,export}` (also aliased as `acf json …` per SCF 6.8.4+) |
186
192
  | Users | `user list` |
187
193
  | Cache | `cache flush`, `transient delete`, `rewrite flush` |
188
194
  | Export | `export` (WXR data export to file) |
@@ -237,12 +243,13 @@ The sentinel grants exactly the extended set above — it does NOT unlock anythi
237
243
 
238
244
  ### Filesystem flag validation
239
245
 
240
- The three DEFAULT-tier filesystem commands (`wp export`, `acf export <path>`, `acf import <path>`) are second-pass validated against a safe root so wrong-path arguments can't write WXR to the web root or read ACF configs from arbitrary locations.
246
+ The DEFAULT-tier filesystem commands (`wp export`, `acf export <path>`, `acf import <path>`, `scf json export --dir=<path>`, `scf json import <file>`, plus the `acf json …` aliases) are second-pass validated against a safe root so wrong-path arguments can't write WXR / schema JSON to the web root or read configs from arbitrary locations.
241
247
 
242
248
  - **Safe root**: `<WP_PATH>/.diviops-tmp/` by default (auto-created on first use in host mode). Override with `DIVIOPS_WP_CLI_SAFE_FS_ROOT=/absolute/path`. All path arguments must canonicalize under this directory; symlinks are resolved via `realpath` so a planted symlink inside the safe root pointing outside it is caught.
243
249
  - **`wp export` must pass `--dir=<path-under-safe-root>`** (or `--stdout`). Without `--dir`, wp-cli writes to the current working directory; on prod that's typically the web root.
244
250
  - **`--filename_format=` must be a filename template**, not a path — separators (`/`, `\`) are rejected so a crafted template can't escape `--dir`'s scope.
245
251
  - **`acf export/import`'s positional path** must resolve under the safe root.
252
+ - **`scf json export`'s `--dir=` flag** must resolve under the safe root (or pass `--stdout` for in-memory transfer). **`scf json import`'s positional `<file>` path** must resolve under the safe root.
246
253
  - **Wrapper mode (`WP_CLI_CMD`)**: the host-derived safe root doesn't correspond to the wrapper's filesystem (e.g., container paths like `/www/app`), so `DIVIOPS_WP_CLI_SAFE_FS_ROOT` is **required** and must be set to the container-namespace path. FS-sensitive commands are rejected with a clear error if it's missing.
247
254
  - **Escape hatch**: `DIVIOPS_WP_CLI_UNSAFE_FS=1` disables validation entirely. Appropriate for trusted single-user local-dev setups that don't want the guard.
248
255
 
@@ -2,7 +2,7 @@
2
2
  * Version compatibility between MCP server and WP plugin.
3
3
  */
4
4
  /** Minimum WP plugin version this server requires. */
5
- export declare const MIN_PLUGIN_VERSION = "1.0.0-beta.43";
5
+ export declare const MIN_PLUGIN_VERSION = "1.0.0-beta.44";
6
6
  /**
7
7
  * Compare two semver-like version strings (supports pre-release tags).
8
8
  *
@@ -2,7 +2,7 @@
2
2
  * Version compatibility between MCP server and WP plugin.
3
3
  */
4
4
  /** Minimum WP plugin version this server requires. */
5
- export const MIN_PLUGIN_VERSION = '1.0.0-beta.43';
5
+ export const MIN_PLUGIN_VERSION = '1.0.0-beta.44';
6
6
  /**
7
7
  * Compare two semver-like version strings (supports pre-release tags).
8
8
  *
package/dist/index.js CHANGED
@@ -521,7 +521,7 @@ server.registerTool("diviops_get_section", {
521
521
  };
522
522
  });
523
523
  server.registerTool("diviops_update_module", {
524
- description: 'Update specific attributes of a module. Target by auto_index (e.g. "text:5"), admin label, or text content. Uses dot notation for attribute paths. Example: {"content.decoration.headingFont.h2.font.desktop.value.color": "#ff0000"}. Priority: auto_index > label > match_text. Use occurrence with label when duplicates exist.',
524
+ 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
525
  inputSchema: {
526
526
  page_id: z.number().describe("WordPress post/page ID"),
527
527
  label: z
@@ -1322,7 +1322,7 @@ server.registerTool("diviops_delete_canvas", {
1322
1322
  });
1323
1323
  // ── WP-CLI ──────────────────────────────────────────────────────────
1324
1324
  server.registerTool("diviops_wp_cli", {
1325
- description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel), or WP_CLI_CMD for containerized wrappers. Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info/core/db, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF schema ops (export/import/list/get field-group), and WXR export. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var) adds destructive or bulk-modifying ops: option update, post/post meta/term delete, search-replace, import, plugin activate/deactivate, eval-file. Filesystem-touching commands (`wp export`, `acf export/import`) are additionally constrained: path arguments must resolve under a safe root (defaults to `<WP_PATH>/.diviops-tmp/`, overridable via DIVIOPS_WP_CLI_SAFE_FS_ROOT, disable via DIVIOPS_WP_CLI_UNSAFE_FS=1); `wp export` requires an explicit `--dir=<path>` (or `--stdout`). In WP_CLI_CMD wrapper mode, DIVIOPS_WP_CLI_SAFE_FS_ROOT is required for FS-sensitive commands. Use --format=json for structured output. Full allowlist + tier rationale + filesystem semantics in the MCP server README.",
1325
+ description: "Run a WP-CLI command on the WordPress site. Requires WP_PATH env var (LOCAL_SITE_ID auto-detected from Local by Flywheel), or WP_CLI_CMD for containerized wrappers. Commands validated against a safety allowlist. Default tier covers read ops across options/posts/post-types/taxonomies/users/info/core/db, non-destructive writes (post/term create+update, post meta read/write, cache/rewrite/transient flush), ACF/SCF schema ops (`acf export/import/field-group list/get` plus SCF 6.8.4+ `scf json {status,sync,import,export}` and the `acf json …` aliases), and WXR export. Extended tier (requires DIVIOPS_WP_CLI_ALLOW env var) adds destructive or bulk-modifying ops: option update, post/post meta/term delete, search-replace, import, plugin activate/deactivate, eval-file. Filesystem-touching commands (`wp export`, `acf export/import`, `scf|acf json export/import`) are additionally constrained: path arguments must resolve under a safe root (defaults to `<WP_PATH>/.diviops-tmp/`, overridable via DIVIOPS_WP_CLI_SAFE_FS_ROOT, disable via DIVIOPS_WP_CLI_UNSAFE_FS=1); `wp export` and `scf json export` require an explicit `--dir=<path>` (or `--stdout`). In WP_CLI_CMD wrapper mode, DIVIOPS_WP_CLI_SAFE_FS_ROOT is required for FS-sensitive commands. Prefer the typed `diviops_scf_*` wrappers for SCF round-trips — they're easier to invoke and accept the same safe-root scoping. Use --format=json for structured output. Full allowlist + tier rationale + filesystem semantics in the MCP server README.",
1326
1326
  inputSchema: {
1327
1327
  command: z
1328
1328
  .string()
@@ -1345,6 +1345,275 @@ server.registerTool("diviops_wp_cli", {
1345
1345
  : `Error: ${result.error}\n${result.output}`;
1346
1346
  return { content: [{ type: "text", text: output }] };
1347
1347
  });
1348
+ // ── SCF (Secure Custom Fields / ACF) wrappers ───────────────────────
1349
+ //
1350
+ // Typed wrappers over SCF 6.8.4+'s `wp scf json {status,sync,import,export}`
1351
+ // CLI family (also reachable as `wp acf json …`). The plugin file at
1352
+ // wp-content/plugins/secure-custom-fields/src/CLI/JsonCommand.php is the
1353
+ // upstream source of truth for flag shapes — keep these wrappers aligned.
1354
+ function ensureWpCli() {
1355
+ if (!wpCli) {
1356
+ return {
1357
+ ok: false,
1358
+ text: "WP-CLI not configured. Set WP_PATH (Local by Flywheel auto-detect) " +
1359
+ "or WP_CLI_CMD (containerized wrappers) to enable SCF round-trip tools.",
1360
+ };
1361
+ }
1362
+ return { ok: true };
1363
+ }
1364
+ function pushScfFlag(args, name, value) {
1365
+ if (!value)
1366
+ return;
1367
+ // Each `--name=value` becomes a single argv entry — execFile handles spaces
1368
+ // and quotes inside the value transparently. No string concatenation, no
1369
+ // parseCommand round-trip, so values like "Bob's Group" or filenames with
1370
+ // spaces flow through verbatim.
1371
+ args.push(`--${name}=${value}`);
1372
+ }
1373
+ server.registerTool("diviops_scf_status", {
1374
+ description: "Show SCF (Secure Custom Fields) sync status — how many field groups, post types, taxonomies, and options pages have JSON-on-disk newer than the database (or absent from DB). Read-only. Wraps `wp scf json status`. Requires SCF 6.8.4+ and WP_PATH or WP_CLI_CMD.",
1375
+ inputSchema: {
1376
+ type: z
1377
+ .enum(["field-group", "post-type", "taxonomy", "options-page"])
1378
+ .optional()
1379
+ .describe("Limit to a single item type. Defaults to all types. options-page requires ACF PRO."),
1380
+ detailed: z
1381
+ .boolean()
1382
+ .optional()
1383
+ .describe("List the individual pending items (key/title/type/action) instead of just counts."),
1384
+ },
1385
+ }, async ({ type, detailed }) => {
1386
+ const gate = ensureWpCli();
1387
+ if (!gate.ok) {
1388
+ return { content: [{ type: "text", text: gate.text }] };
1389
+ }
1390
+ const args = ["scf", "json", "status", "--format=json"];
1391
+ pushScfFlag(args, "type", type);
1392
+ if (detailed)
1393
+ args.push("--detailed");
1394
+ const result = await wpCli.runArgs(args);
1395
+ const output = result.success
1396
+ ? result.output
1397
+ : `Error: ${result.error}\n${result.output}`;
1398
+ return { content: [{ type: "text", text: output }] };
1399
+ });
1400
+ server.registerTool("diviops_scf_export", {
1401
+ description: "Export SCF field groups, post types, taxonomies, and options pages as JSON — to a directory under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT) or to stdout. Wraps `wp scf json export`. Either `dir` or `stdout: true` is required. Filters can be combined; without filters, all items are exported. Note: SCF writes a fixed filename `acf-export-YYYY-MM-DD.json` inside `dir` — two exports on the same day silently overwrite. Copy/rename if you're archiving baselines.",
1402
+ inputSchema: {
1403
+ dir: z
1404
+ .string()
1405
+ .optional()
1406
+ .describe("Absolute output directory under the WP-CLI safe-root. Mutually exclusive with `stdout`. SCF writes a single `acf-export-YYYY-MM-DD.json` file inside this dir."),
1407
+ stdout: z
1408
+ .boolean()
1409
+ .optional()
1410
+ .describe("Print JSON to stdout instead of writing a file. Mutually exclusive with `dir`."),
1411
+ field_groups: z
1412
+ .string()
1413
+ .optional()
1414
+ .describe("Comma-separated field-group ACF keys (`group_abc123`) or admin titles (`My Field Group`). NOT WP post slugs — SCF matches against the def's `key` field or its `title` (case-insensitive). Use `diviops_scf_list_field_groups` to discover keys (post_name column)."),
1415
+ post_types: z
1416
+ .string()
1417
+ .optional()
1418
+ .describe("Comma-separated SCF post-type def keys (`post_type_xxx`) or admin titles (`Programm`). IMPORTANT: this is the SCF def's identifier, NOT the registered post-type slug (`event`, `book`). The registered slug is what `wp post list` and REST URLs use, but SCF's filter matches against the def's `key` field or its `title`. To discover def keys, run `diviops_scf_export --stdout` (no filter) and inspect the top-level entries with `parent='post-type'`."),
1419
+ taxonomies: z
1420
+ .string()
1421
+ .optional()
1422
+ .describe("Comma-separated SCF taxonomy def keys (`taxonomy_xxx`) or admin titles. Same caveat as `post_types`: NOT the registered taxonomy slug — the SCF def's `key` or `title`. Discover via `diviops_scf_export --stdout`."),
1423
+ options_pages: z
1424
+ .string()
1425
+ .optional()
1426
+ .describe("Comma-separated options-page def keys or admin titles. Requires ACF PRO."),
1427
+ },
1428
+ }, async ({ dir, stdout, field_groups, post_types, taxonomies, options_pages }) => {
1429
+ const gate = ensureWpCli();
1430
+ if (!gate.ok) {
1431
+ return { content: [{ type: "text", text: gate.text }] };
1432
+ }
1433
+ if (!dir && !stdout) {
1434
+ return {
1435
+ content: [
1436
+ {
1437
+ type: "text",
1438
+ text: "Error: pass either `dir` (absolute path under DIVIOPS_WP_CLI_SAFE_FS_ROOT) or `stdout: true`.",
1439
+ },
1440
+ ],
1441
+ };
1442
+ }
1443
+ if (dir && stdout) {
1444
+ return {
1445
+ content: [
1446
+ {
1447
+ type: "text",
1448
+ text: "Error: `dir` and `stdout` are mutually exclusive — pick one.",
1449
+ },
1450
+ ],
1451
+ };
1452
+ }
1453
+ const args = ["scf", "json", "export"];
1454
+ if (stdout)
1455
+ args.push("--stdout");
1456
+ pushScfFlag(args, "dir", dir);
1457
+ pushScfFlag(args, "field-groups", field_groups);
1458
+ pushScfFlag(args, "post-types", post_types);
1459
+ pushScfFlag(args, "taxonomies", taxonomies);
1460
+ pushScfFlag(args, "options-pages", options_pages);
1461
+ const result = await wpCli.runArgs(args);
1462
+ const output = result.success
1463
+ ? result.output
1464
+ : `Error: ${result.error}\n${result.output}`;
1465
+ return { content: [{ type: "text", text: output }] };
1466
+ });
1467
+ server.registerTool("diviops_scf_import", {
1468
+ description: "Import SCF field groups, post types, taxonomies, options pages from a JSON file. Mutates the database. File path must resolve under the safe-root (`<WP_PATH>/.diviops-tmp/` by default, override via DIVIOPS_WP_CLI_SAFE_FS_ROOT). Idempotent — existing items with matching keys are updated. Wraps `wp scf json import <file>`.",
1469
+ inputSchema: {
1470
+ file: z
1471
+ .string()
1472
+ .describe("Absolute path to the .json file to import. Must resolve under DIVIOPS_WP_CLI_SAFE_FS_ROOT."),
1473
+ },
1474
+ }, async ({ file }) => {
1475
+ const gate = ensureWpCli();
1476
+ if (!gate.ok) {
1477
+ return { content: [{ type: "text", text: gate.text }] };
1478
+ }
1479
+ const result = await wpCli.runArgs(["scf", "json", "import", file]);
1480
+ const output = result.success
1481
+ ? result.output
1482
+ : `Error: ${result.error}\n${result.output}`;
1483
+ return { content: [{ type: "text", text: output }] };
1484
+ });
1485
+ server.registerTool("diviops_scf_sync", {
1486
+ description: "Apply pending JSON-on-disk SCF changes to the database. Reads JSON files from the theme/plugin acf-json directory and creates/updates DB entries. Defaults to `dry_run: true` for safety — caller must opt in to mutation. Wraps `wp scf json sync`.",
1487
+ inputSchema: {
1488
+ type: z
1489
+ .enum(["field-group", "post-type", "taxonomy", "options-page"])
1490
+ .optional()
1491
+ .describe("Limit sync to a single item type."),
1492
+ key: z
1493
+ .string()
1494
+ .optional()
1495
+ .describe("Sync only the item with this ACF key (e.g. `group_abc123`)."),
1496
+ dry_run: z
1497
+ .boolean()
1498
+ .optional()
1499
+ .default(true)
1500
+ .describe("Preview pending changes without mutating the database. Defaults to true. Pass `false` to commit."),
1501
+ },
1502
+ }, async ({ type, key, dry_run }) => {
1503
+ const gate = ensureWpCli();
1504
+ if (!gate.ok) {
1505
+ return { content: [{ type: "text", text: gate.text }] };
1506
+ }
1507
+ const args = ["scf", "json", "sync"];
1508
+ pushScfFlag(args, "type", type);
1509
+ pushScfFlag(args, "key", key);
1510
+ if (dry_run !== false)
1511
+ args.push("--dry-run");
1512
+ const result = await wpCli.runArgs(args);
1513
+ const output = result.success
1514
+ ? result.output
1515
+ : `Error: ${result.error}\n${result.output}`;
1516
+ return { content: [{ type: "text", text: output }] };
1517
+ });
1518
+ server.registerTool("diviops_scf_list_field_groups", {
1519
+ description: "List all SCF/ACF field groups in the database (post_name = ACF key, post_title, post_status, post_modified). Read-only. Queries the underlying `acf-field-group` post type via `wp post list` — works on both SCF 6.8.4+ (which dropped the legacy `wp acf field-group …` family in favor of the `wp scf json` namespace) and older ACF installs.",
1520
+ }, async () => {
1521
+ const gate = ensureWpCli();
1522
+ if (!gate.ok) {
1523
+ return { content: [{ type: "text", text: gate.text }] };
1524
+ }
1525
+ const result = await wpCli.runArgs([
1526
+ "post",
1527
+ "list",
1528
+ "--post_type=acf-field-group",
1529
+ "--post_status=any",
1530
+ "--fields=ID,post_name,post_title,post_status,post_modified",
1531
+ "--format=json",
1532
+ ]);
1533
+ const output = result.success
1534
+ ? result.output
1535
+ : `Error: ${result.error}\n${result.output}`;
1536
+ return { content: [{ type: "text", text: output }] };
1537
+ });
1538
+ server.registerTool("diviops_scf_get_field_group", {
1539
+ description: "Fetch a single SCF/ACF field group from the `acf-field-group` post type — by ACF key (`group_abc123`, looked up via `post_name`) or by numeric WP post ID. Returns the WP post fields (post_name, post_title, post_content with serialized fields blob, post_status, post_modified). For the parsed/structured field tree including nested fields, use `diviops_scf_export --field-groups=<key> --stdout` instead. Read-only. SCF 6.8.4 dropped the legacy `wp acf field-group get` command, so this wrapper queries the post type directly via `wp post`.",
1540
+ inputSchema: {
1541
+ key: z
1542
+ .string()
1543
+ .describe("ACF field-group key (`group_abc123`, matched against post_name) or numeric WP post ID."),
1544
+ },
1545
+ }, async ({ key }) => {
1546
+ const gate = ensureWpCli();
1547
+ if (!gate.ok) {
1548
+ return { content: [{ type: "text", text: gate.text }] };
1549
+ }
1550
+ // If the input looks like a numeric ID, hand it to `wp post get` directly.
1551
+ // Otherwise treat it as an ACF key and resolve via post_name first.
1552
+ const isNumericId = /^\d+$/.test(key);
1553
+ if (isNumericId) {
1554
+ const result = await wpCli.runArgs([
1555
+ "post",
1556
+ "get",
1557
+ key,
1558
+ "--format=json",
1559
+ ]);
1560
+ const output = result.success
1561
+ ? result.output
1562
+ : `Error: ${result.error}\n${result.output}`;
1563
+ return { content: [{ type: "text", text: output }] };
1564
+ }
1565
+ // Resolve ACF key → post ID via `wp post list --name=<key>`. Single-row
1566
+ // lookup; returns [] if the key isn't found.
1567
+ const lookup = await wpCli.runArgs([
1568
+ "post",
1569
+ "list",
1570
+ "--post_type=acf-field-group",
1571
+ "--post_status=any",
1572
+ `--name=${key}`,
1573
+ "--fields=ID",
1574
+ "--format=json",
1575
+ ]);
1576
+ if (!lookup.success) {
1577
+ return {
1578
+ content: [
1579
+ {
1580
+ type: "text",
1581
+ text: `Error looking up field-group key "${key}": ${lookup.error}\n${lookup.output}`,
1582
+ },
1583
+ ],
1584
+ };
1585
+ }
1586
+ let postId = null;
1587
+ try {
1588
+ const rows = JSON.parse(lookup.output);
1589
+ if (Array.isArray(rows) && rows.length > 0) {
1590
+ postId = String(rows[0].ID);
1591
+ }
1592
+ }
1593
+ catch {
1594
+ // Fall through — postId stays null, return a clear "not found" error.
1595
+ }
1596
+ if (!postId) {
1597
+ return {
1598
+ content: [
1599
+ {
1600
+ type: "text",
1601
+ text: `No field-group found for key "${key}". Use diviops_scf_list_field_groups to see available keys (post_name field).`,
1602
+ },
1603
+ ],
1604
+ };
1605
+ }
1606
+ const result = await wpCli.runArgs([
1607
+ "post",
1608
+ "get",
1609
+ postId,
1610
+ "--format=json",
1611
+ ]);
1612
+ const output = result.success
1613
+ ? result.output
1614
+ : `Error: ${result.error}\n${result.output}`;
1615
+ return { content: [{ type: "text", text: output }] };
1616
+ });
1348
1617
  // ── Connection ──────────────────────────────────────────────────────
1349
1618
  server.registerTool("diviops_test_connection", {
1350
1619
  description: "Test the connection to the WordPress site and verify the Divi MCP plugin is active.",
@@ -53,8 +53,12 @@ export declare function fsValidationDisabled(): boolean;
53
53
  export declare function ensureSafeFsRoot(safeRoot: string): void;
54
54
  /**
55
55
  * Identify which FS-sensitive command a parsed arg vector represents.
56
- * Returns the canonical prefix ("export", "acf export", "acf import") or
57
- * null if the args don't match a FS-sensitive command.
56
+ * Returns the canonical prefix ("export", "acf export", "scf json export")
57
+ * or null if the args don't match a FS-sensitive command.
58
+ *
59
+ * Checks 3-, 2-, then 1-word prefixes — longer matches win so that
60
+ * `scf json export` is identified as the SCF command (not bare `export`)
61
+ * even though both share the trailing word.
58
62
  */
59
63
  export declare function matchFsSensitiveCommand(args: string[]): string | null;
60
64
  /**
@@ -31,11 +31,19 @@ const SAFE_FS_ROOT_OVERRIDE_ENV = 'DIVIOPS_WP_CLI_SAFE_FS_ROOT';
31
31
  * DEFAULT-tier commands whose arguments can write/read to arbitrary paths.
32
32
  * EXTENDED-tier FS commands (`import`, `eval-file`) are opt-in and not
33
33
  * validated here — opting in implies accepting the path-scope risk.
34
+ *
35
+ * SCF 6.8.4 introduced `wp scf json export|import` (and `wp acf json …`
36
+ * aliases). `export` takes `--dir=<directory>` (flag, like `wp export`);
37
+ * `import` takes a positional `<file>` path (like the legacy `acf import`).
34
38
  */
35
39
  const FS_SENSITIVE_COMMANDS = [
36
40
  'export',
37
41
  'acf export',
38
42
  'acf import',
43
+ 'scf json export',
44
+ 'scf json import',
45
+ 'acf json export',
46
+ 'acf json import',
39
47
  ];
40
48
  /**
41
49
  * Resolve the effective safe filesystem root for this wp-cli instance.
@@ -70,12 +78,19 @@ export function ensureSafeFsRoot(safeRoot) {
70
78
  }
71
79
  /**
72
80
  * Identify which FS-sensitive command a parsed arg vector represents.
73
- * Returns the canonical prefix ("export", "acf export", "acf import") or
74
- * null if the args don't match a FS-sensitive command.
81
+ * Returns the canonical prefix ("export", "acf export", "scf json export")
82
+ * or null if the args don't match a FS-sensitive command.
83
+ *
84
+ * Checks 3-, 2-, then 1-word prefixes — longer matches win so that
85
+ * `scf json export` is identified as the SCF command (not bare `export`)
86
+ * even though both share the trailing word.
75
87
  */
76
88
  export function matchFsSensitiveCommand(args) {
77
89
  if (args.length === 0)
78
90
  return null;
91
+ const threeWord = args.slice(0, 3).join(' ');
92
+ if (FS_SENSITIVE_COMMANDS.includes(threeWord))
93
+ return threeWord;
79
94
  const twoWord = args.slice(0, 2).join(' ');
80
95
  if (FS_SENSITIVE_COMMANDS.includes(twoWord))
81
96
  return twoWord;
@@ -333,5 +348,52 @@ export function validateFilesystemFlags(args, safeRoot, opts = {}) {
333
348
  }
334
349
  return { allowed: true };
335
350
  }
351
+ if (cmd === 'scf json export' || cmd === 'acf json export') {
352
+ // SCF 6.8.4's `wp scf|acf json export` uses `--dir=<dir>` (or `--stdout`)
353
+ // for output destination — same flag shape as `wp export`. Reuse the same
354
+ // extractor; `--filename_format` is `wp export`-only and irrelevant here.
355
+ const { dir, stdout } = extractExportFlags(args);
356
+ if (stdout) {
357
+ // Writes JSON to stdout; no FS write to validate.
358
+ return { allowed: true };
359
+ }
360
+ if (!dir) {
361
+ // Let wp-cli surface its own "must specify --dir or --stdout" error
362
+ // rather than returning a redundant allowlist rejection.
363
+ return { allowed: true };
364
+ }
365
+ const rel = rejectIfRelative(dir, `${cmd} --dir`, safeRootCanonical);
366
+ if (rel)
367
+ return rel;
368
+ if (!isPathUnderSafeRoot(dir, safeRootCanonical)) {
369
+ return {
370
+ allowed: false,
371
+ reason: `${cmd} --dir="${dir}" resolves outside the safe filesystem root ` +
372
+ `"${safeRootCanonical}". Use a path under the safe root, or set ` +
373
+ `${UNSAFE_FS_ENV}=1 to disable validation.`,
374
+ };
375
+ }
376
+ return { allowed: true };
377
+ }
378
+ if (cmd === 'scf json import' || cmd === 'acf json import') {
379
+ // `wp scf|acf json import <file>` — positional file path at args[3]
380
+ // (after the 3-word command prefix). Same safe-root rules as `acf import`.
381
+ const userPath = extractPositionalAfterPrefix(args, 3);
382
+ if (!userPath) {
383
+ return { allowed: true };
384
+ }
385
+ const rel = rejectIfRelative(userPath, cmd, safeRootCanonical);
386
+ if (rel)
387
+ return rel;
388
+ if (!isPathUnderSafeRoot(userPath, safeRootCanonical)) {
389
+ return {
390
+ allowed: false,
391
+ reason: `${cmd} "${userPath}" resolves outside the safe filesystem root ` +
392
+ `"${safeRootCanonical}". Use a path under the safe root, or set ` +
393
+ `${UNSAFE_FS_ENV}=1 to disable validation.`,
394
+ };
395
+ }
396
+ return { allowed: true };
397
+ }
336
398
  return { allowed: true };
337
399
  }
package/dist/wp-cli.d.ts CHANGED
@@ -15,15 +15,31 @@ interface WpCliConfig {
15
15
  }
16
16
  export declare function createWpCli(config: WpCliConfig): {
17
17
  /**
18
- * Execute a WP-CLI command. Returns stdout on success.
19
- * Commands are parsed into args and validated against an allowlist.
20
- * Uses execFile (no shell) to prevent command injection.
18
+ * Execute a WP-CLI command from a string. Parsed via parseCommand
19
+ * (single/double-quote toggling, no escape support). Validated against
20
+ * the allowlist. Uses execFile (no shell) to prevent command injection.
21
+ *
22
+ * Prefer `runArgs` from typed wrappers — it skips parseCommand entirely
23
+ * so user-supplied values containing apostrophes/quotes flow through
24
+ * verbatim instead of being mis-split.
21
25
  */
22
26
  run(command: string): Promise<{
23
27
  success: boolean;
24
28
  output: string;
25
29
  error?: string;
26
30
  }>;
31
+ /**
32
+ * Execute a WP-CLI command from a pre-built argv array. Skips
33
+ * parseCommand so values containing whitespace, apostrophes, or
34
+ * quotes pass through unmodified. Same allowlist + FS-safe-root
35
+ * validation as `run`. Use this from typed wrappers that already
36
+ * have the args structured (no string concatenation needed).
37
+ */
38
+ runArgs(args: string[]): Promise<{
39
+ success: boolean;
40
+ output: string;
41
+ error?: string;
42
+ }>;
27
43
  /** Return the list of allowed commands and available extensions. */
28
44
  getAllowedCommands(): {
29
45
  allowed: string[];
package/dist/wp-cli.js CHANGED
@@ -40,6 +40,20 @@ const DEFAULT_COMMANDS = [
40
40
  'acf import',
41
41
  'acf field-group list',
42
42
  'acf field-group get',
43
+ // SCF 6.8.4+ adds `wp scf json {status,sync,import,export}` (also aliased as
44
+ // `wp acf json …`). All four are dev-time schema ops — `status`/`sync` are
45
+ // diff/apply against on-disk JSON, `import` re-creates DB entries from JSON,
46
+ // `export` writes DB schema to JSON. Same tier semantics as the legacy `acf`
47
+ // entries above; FS-touching subcommands (export, import) are second-pass
48
+ // validated below.
49
+ 'scf json status',
50
+ 'scf json sync',
51
+ 'scf json import',
52
+ 'scf json export',
53
+ 'acf json status',
54
+ 'acf json sync',
55
+ 'acf json import',
56
+ 'acf json export',
43
57
  // Users (read-only)
44
58
  'user list',
45
59
  // Cache (non-destructive maintenance)
@@ -319,66 +333,91 @@ export function createWpCli(config) {
319
333
  const runOptions = customWpCliCmd
320
334
  ? { ...execOptions, env, cwd: config.wpPath }
321
335
  : { ...execOptions, env };
322
- return {
323
- /**
324
- * Execute a WP-CLI command. Returns stdout on success.
325
- * Commands are parsed into args and validated against an allowlist.
326
- * Uses execFile (no shell) to prevent command injection.
327
- */
328
- async run(command) {
329
- const args = parseCommand(command);
330
- const check = isCommandAllowed(args);
331
- if (!check.allowed) {
332
- return { success: false, output: '', error: check.reason };
336
+ // Internal argv-based runner — used by both `run(string)` (after
337
+ // parseCommand) and `runArgs(string[])` (skip parseCommand). Typed
338
+ // wrappers should call runArgs to bypass parseCommand's quote-toggling
339
+ // weakness: when a value contains an apostrophe (e.g. label "Bob's
340
+ // Group", file path /tmp/it's-fine.json), parseCommand mis-splits the
341
+ // argv because it treats the embedded `'` as a quote toggle. Passing
342
+ // pre-built argv eliminates the parsing step entirely so user-provided
343
+ // strings flow through verbatim — execFile (no shell) handles them
344
+ // correctly. Raised in PR #473 review (Copilot/Gemini both flagged).
345
+ const runArgv = async (args) => {
346
+ const check = isCommandAllowed(args);
347
+ if (!check.allowed) {
348
+ return { success: false, output: '', error: check.reason };
349
+ }
350
+ // Second-pass FS validation for commands whose flags/args can read/write
351
+ // arbitrary paths. Scoped to DEFAULT-tier FS commands only — EXTENDED
352
+ // (`import`, `eval-file`) are opt-in via DIVIOPS_WP_CLI_ALLOW, so opting
353
+ // in signals the caller accepts path-scope risk. Skip entirely when the
354
+ // user explicitly disables via DIVIOPS_WP_CLI_UNSAFE_FS=1.
355
+ //
356
+ // Wrapper mode (WP_CLI_CMD set) is gated separately inside
357
+ // validateFilesystemFlags — host-derived safe roots don't correspond to
358
+ // the wrapper's filesystem namespace, so the validator requires an
359
+ // explicit DIVIOPS_WP_CLI_SAFE_FS_ROOT there. We also skip the host-side
360
+ // mkdir in wrapper mode since the safe root is either container-scoped
361
+ // (user-managed) or unset (validator rejects).
362
+ if (!fsValidationDisabled() && matchFsSensitiveCommand(args)) {
363
+ const safeRoot = resolveSafeFsRoot(config.wpPath);
364
+ if (!customWpCliCmd) {
365
+ ensureSafeFsRoot(safeRoot);
333
366
  }
334
- // Second-pass FS validation for commands whose flags/args can read/write
335
- // arbitrary paths. Scoped to DEFAULT-tier FS commands only — EXTENDED
336
- // (`import`, `eval-file`) are opt-in via DIVIOPS_WP_CLI_ALLOW, so opting
337
- // in signals the caller accepts path-scope risk. Skip entirely when the
338
- // user explicitly disables via DIVIOPS_WP_CLI_UNSAFE_FS=1.
339
- //
340
- // Wrapper mode (WP_CLI_CMD set) is gated separately inside
341
- // validateFilesystemFlags host-derived safe roots don't correspond to
342
- // the wrapper's filesystem namespace, so the validator requires an
343
- // explicit DIVIOPS_WP_CLI_SAFE_FS_ROOT there. We also skip the host-side
344
- // mkdir in wrapper mode since the safe root is either container-scoped
345
- // (user-managed) or unset (validator rejects).
346
- if (!fsValidationDisabled() && matchFsSensitiveCommand(args)) {
347
- const safeRoot = resolveSafeFsRoot(config.wpPath);
348
- if (!customWpCliCmd) {
349
- ensureSafeFsRoot(safeRoot);
367
+ const fsCheck = validateFilesystemFlags(args, safeRoot, {
368
+ isWrapper: !!customWpCliCmd,
369
+ });
370
+ if (!fsCheck.allowed) {
371
+ return { success: false, output: '', error: fsCheck.reason };
372
+ }
373
+ }
374
+ const fullArgs = customWpCliCmd
375
+ ? [...prefixArgs, ...args, '--no-color']
376
+ : [...args, `--path=${config.wpPath}`, '--no-color'];
377
+ return new Promise((resolve) => {
378
+ execFile(executable, fullArgs, runOptions, (error, stdout, stderr) => {
379
+ // Filter PHP deprecation warnings from output
380
+ const output = (stdout + '\n' + stderr)
381
+ .split('\n')
382
+ .filter((line) => !line.includes('Deprecated:') && !line.includes('PHP Deprecated'))
383
+ .join('\n')
384
+ .trim();
385
+ if (error) {
386
+ const detail = error.killed
387
+ ? 'Command timed out'
388
+ : error.signal
389
+ ? `Killed by signal ${error.signal}`
390
+ : `Exit code ${error.code ?? 'unknown'}`;
391
+ resolve({ success: false, output, error: detail });
350
392
  }
351
- const fsCheck = validateFilesystemFlags(args, safeRoot, {
352
- isWrapper: !!customWpCliCmd,
353
- });
354
- if (!fsCheck.allowed) {
355
- return { success: false, output: '', error: fsCheck.reason };
393
+ else {
394
+ resolve({ success: true, output });
356
395
  }
357
- }
358
- const fullArgs = customWpCliCmd
359
- ? [...prefixArgs, ...args, '--no-color']
360
- : [...args, `--path=${config.wpPath}`, '--no-color'];
361
- return new Promise((resolve) => {
362
- execFile(executable, fullArgs, runOptions, (error, stdout, stderr) => {
363
- // Filter PHP deprecation warnings from output
364
- const output = (stdout + '\n' + stderr)
365
- .split('\n')
366
- .filter((line) => !line.includes('Deprecated:') && !line.includes('PHP Deprecated'))
367
- .join('\n')
368
- .trim();
369
- if (error) {
370
- const detail = error.killed
371
- ? 'Command timed out'
372
- : error.signal
373
- ? `Killed by signal ${error.signal}`
374
- : `Exit code ${error.code ?? 'unknown'}`;
375
- resolve({ success: false, output, error: detail });
376
- }
377
- else {
378
- resolve({ success: true, output });
379
- }
380
- });
381
396
  });
397
+ });
398
+ };
399
+ return {
400
+ /**
401
+ * Execute a WP-CLI command from a string. Parsed via parseCommand
402
+ * (single/double-quote toggling, no escape support). Validated against
403
+ * the allowlist. Uses execFile (no shell) to prevent command injection.
404
+ *
405
+ * Prefer `runArgs` from typed wrappers — it skips parseCommand entirely
406
+ * so user-supplied values containing apostrophes/quotes flow through
407
+ * verbatim instead of being mis-split.
408
+ */
409
+ async run(command) {
410
+ return runArgv(parseCommand(command));
411
+ },
412
+ /**
413
+ * Execute a WP-CLI command from a pre-built argv array. Skips
414
+ * parseCommand so values containing whitespace, apostrophes, or
415
+ * quotes pass through unmodified. Same allowlist + FS-safe-root
416
+ * validation as `run`. Use this from typed wrappers that already
417
+ * have the args structured (no string concatenation needed).
418
+ */
419
+ async runArgs(args) {
420
+ return runArgv(args);
382
421
  },
383
422
  /** Return the list of allowed commands and available extensions. */
384
423
  getAllowedCommands() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diviops/mcp-server",
3
- "version": "0.2.28",
3
+ "version": "0.2.29",
4
4
  "description": "MCP server exposing Divi 5 Visual Builder as tools for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",