@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.
- package/README.md +66 -59
- package/dist/compatibility.d.ts +1 -1
- package/dist/compatibility.js +1 -1
- package/dist/index.js +387 -118
- package/dist/wp-cli-fs-validator.d.ts +6 -2
- package/dist/wp-cli-fs-validator.js +64 -2
- package/dist/wp-cli.d.ts +19 -3
- package/dist/wp-cli.js +95 -56
- package/dist/wp-client.js +4 -4
- package/package.json +1 -1
|
@@ -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", "
|
|
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", "
|
|
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.
|
|
19
|
-
*
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
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
|
-
* `
|
|
29
|
+
* `section_get` markup (which emits inner quotes as `"` 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
|
-
//
|
|
71
|
-
//
|
|
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'}`,
|