@diviops/mcp-server 0.2.28 → 1.0.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.
@@ -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/dist/wp-client.js CHANGED
@@ -26,7 +26,7 @@ import { MIN_PLUGIN_VERSION, compareVersions, } from './compatibility.js';
26
26
  * `0022` into emitted CSS).
27
27
  *
28
28
  * Under-escape (#409 fix). One form produced when an agent transcribes
29
- * `get_section` markup (which emits inner quotes as `&quot;` HTML entities) and
29
+ * `section_get` markup (which emits inner quotes as `&quot;` HTML entities) and
30
30
  * a layer in the agent → MCP → WP pipeline strips one level of escaping:
31
31
  * - bare `"` (1 byte) — the inner quote loses its `\` prefix and prematurely
32
32
  * terminates the OUTER block-attrs string at parse time. The WP block
@@ -67,8 +67,8 @@ function normalizeQuoteEscapes(s) {
67
67
  */
68
68
  const BLOCK_CONTENT_KEYS = new Set([
69
69
  'content', // update_page_content, render_preview, validate_blocks,
70
- // append_section, replace_section, update_tb_layout,
71
- // save_to_library, create_page
70
+ // section_append, section_replace, update_tb_layout,
71
+ // library_save, create_page
72
72
  'attrs', // update_module — attr values embedded in block JSON
73
73
  'header_content', // create_tb_template
74
74
  'footer_content', // create_tb_template
@@ -147,7 +147,7 @@ export class WPClient {
147
147
  */
148
148
  async testConnection() {
149
149
  try {
150
- const result = await this.request('/settings');
150
+ const result = await this.request('/schema/settings');
151
151
  return {
152
152
  ok: true,
153
153
  message: `Connected to Divi ${result.builder?.version ?? 'unknown'}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diviops/mcp-server",
3
- "version": "0.2.28",
3
+ "version": "1.0.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",