@diviops/mcp-server 1.4.0 → 1.5.2
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 +128 -299
- package/dist/envelope.d.ts +117 -0
- package/dist/envelope.js +171 -0
- package/dist/index.js +846 -428
- package/dist/wp-cli.d.ts +8 -0
- package/dist/wp-cli.js +81 -20
- package/dist/wp-client.d.ts +38 -0
- package/dist/wp-client.js +147 -2
- package/package.json +1 -1
package/dist/wp-cli.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export declare function createWpCli(config: WpCliConfig): {
|
|
|
27
27
|
success: boolean;
|
|
28
28
|
output: string;
|
|
29
29
|
error?: string;
|
|
30
|
+
stdout: string;
|
|
31
|
+
stderr: string;
|
|
32
|
+
exitCode: number | null;
|
|
33
|
+
failureKind?: "rejected" | "spawn_failed" | "killed" | "exited";
|
|
30
34
|
}>;
|
|
31
35
|
/**
|
|
32
36
|
* Execute a WP-CLI command from a pre-built argv array. Skips
|
|
@@ -39,6 +43,10 @@ export declare function createWpCli(config: WpCliConfig): {
|
|
|
39
43
|
success: boolean;
|
|
40
44
|
output: string;
|
|
41
45
|
error?: string;
|
|
46
|
+
stdout: string;
|
|
47
|
+
stderr: string;
|
|
48
|
+
exitCode: number | null;
|
|
49
|
+
failureKind?: "rejected" | "spawn_failed" | "killed" | "exited";
|
|
42
50
|
}>;
|
|
43
51
|
/** Return the list of allowed commands and available extensions. */
|
|
44
52
|
getAllowedCommands(): {
|
package/dist/wp-cli.js
CHANGED
|
@@ -62,9 +62,14 @@ const DEFAULT_COMMANDS = [
|
|
|
62
62
|
'rewrite flush',
|
|
63
63
|
// Export (reads data, writes to file only)
|
|
64
64
|
'export',
|
|
65
|
-
// Info (read-only)
|
|
65
|
+
// Info (read-only + non-destructive writes)
|
|
66
66
|
'cron event list',
|
|
67
67
|
'plugin list',
|
|
68
|
+
// `plugin update` runs from authenticated sources (WP plugin repo or licensed
|
|
69
|
+
// update server) — same trust model as `core check-update`. Unlike `plugin
|
|
70
|
+
// activate` / `plugin deactivate` (extended tier), it does not enable
|
|
71
|
+
// previously-disabled code paths; it refreshes already-installed plugins.
|
|
72
|
+
'plugin update',
|
|
68
73
|
'theme list',
|
|
69
74
|
'menu list',
|
|
70
75
|
'site url',
|
|
@@ -333,19 +338,18 @@ export function createWpCli(config) {
|
|
|
333
338
|
const runOptions = customWpCliCmd
|
|
334
339
|
? { ...execOptions, env, cwd: config.wpPath }
|
|
335
340
|
: { ...execOptions, env };
|
|
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
341
|
const runArgv = async (args) => {
|
|
346
342
|
const check = isCommandAllowed(args);
|
|
347
343
|
if (!check.allowed) {
|
|
348
|
-
return {
|
|
344
|
+
return {
|
|
345
|
+
success: false,
|
|
346
|
+
output: '',
|
|
347
|
+
error: check.reason,
|
|
348
|
+
stdout: '',
|
|
349
|
+
stderr: '',
|
|
350
|
+
exitCode: null,
|
|
351
|
+
failureKind: 'rejected',
|
|
352
|
+
};
|
|
349
353
|
}
|
|
350
354
|
// Second-pass FS validation for commands whose flags/args can read/write
|
|
351
355
|
// arbitrary paths. Scoped to DEFAULT-tier FS commands only — EXTENDED
|
|
@@ -368,7 +372,15 @@ export function createWpCli(config) {
|
|
|
368
372
|
isWrapper: !!customWpCliCmd,
|
|
369
373
|
});
|
|
370
374
|
if (!fsCheck.allowed) {
|
|
371
|
-
return {
|
|
375
|
+
return {
|
|
376
|
+
success: false,
|
|
377
|
+
output: '',
|
|
378
|
+
error: fsCheck.reason,
|
|
379
|
+
stdout: '',
|
|
380
|
+
stderr: '',
|
|
381
|
+
exitCode: null,
|
|
382
|
+
failureKind: 'rejected',
|
|
383
|
+
};
|
|
372
384
|
}
|
|
373
385
|
}
|
|
374
386
|
const fullArgs = customWpCliCmd
|
|
@@ -376,22 +388,71 @@ export function createWpCli(config) {
|
|
|
376
388
|
: [...args, `--path=${config.wpPath}`, '--no-color'];
|
|
377
389
|
return new Promise((resolve) => {
|
|
378
390
|
execFile(executable, fullArgs, runOptions, (error, stdout, stderr) => {
|
|
379
|
-
// Filter PHP deprecation warnings from
|
|
391
|
+
// Filter PHP deprecation warnings from the legacy concatenated
|
|
392
|
+
// `output` field; `stdout` / `stderr` siblings are kept raw so
|
|
393
|
+
// envelope adopters (meta_wp_cli passthrough, scf_* round-trip)
|
|
394
|
+
// see the unfiltered streams.
|
|
380
395
|
const output = (stdout + '\n' + stderr)
|
|
381
396
|
.split('\n')
|
|
382
397
|
.filter((line) => !line.includes('Deprecated:') && !line.includes('PHP Deprecated'))
|
|
383
398
|
.join('\n')
|
|
384
399
|
.trim();
|
|
385
400
|
if (error) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
401
|
+
// execFile callback fired with an error. Three possible
|
|
402
|
+
// sub-cases; classifying them up-front so the handler can emit
|
|
403
|
+
// accurate hints per cause:
|
|
404
|
+
//
|
|
405
|
+
// - `error.killed === true` → timeout (Node sets
|
|
406
|
+
// this when the configured `timeout` expires).
|
|
407
|
+
// - `error.signal != null` → killed by signal.
|
|
408
|
+
// - typeof error.code === 'number' → child ran and exited
|
|
409
|
+
// with a numeric exit code (the normal "exited non-zero"
|
|
410
|
+
// case).
|
|
411
|
+
// - typeof error.code === 'string' or undefined → spawn-side
|
|
412
|
+
// failure. ENOENT (binary missing), EACCES (not executable),
|
|
413
|
+
// EPERM, etc. — execFile reports these via the same error
|
|
414
|
+
// callback but the child never started. Distinct from
|
|
415
|
+
// "killed" because there's no partial output to preserve
|
|
416
|
+
// and the fix path is environmental (PATH, install, perms),
|
|
417
|
+
// not "raise the timeout."
|
|
418
|
+
const codeRaw = error.code;
|
|
419
|
+
const isKilled = error.killed === true || error.signal != null;
|
|
420
|
+
const isExited = typeof codeRaw === 'number';
|
|
421
|
+
const exitCode = isExited ? codeRaw : null;
|
|
422
|
+
const failureKind = isKilled
|
|
423
|
+
? 'killed'
|
|
424
|
+
: isExited
|
|
425
|
+
? 'exited'
|
|
426
|
+
: 'spawn_failed';
|
|
427
|
+
const detail = isKilled
|
|
428
|
+
? error.killed
|
|
429
|
+
? 'Command timed out'
|
|
430
|
+
: `Killed by signal ${error.signal}`
|
|
431
|
+
: isExited
|
|
432
|
+
? `Exit code ${codeRaw}`
|
|
433
|
+
: // spawn_failed — surface the system errno as the detail
|
|
434
|
+
// string ("Spawn failed: ENOENT") so the cause is visible
|
|
435
|
+
// without parsing structured fields. Falls back to the
|
|
436
|
+
// raw error.message when `code` is empty.
|
|
437
|
+
`Spawn failed: ${codeRaw ?? error.message ?? 'unknown'}`;
|
|
438
|
+
resolve({
|
|
439
|
+
success: false,
|
|
440
|
+
output,
|
|
441
|
+
error: detail,
|
|
442
|
+
stdout,
|
|
443
|
+
stderr,
|
|
444
|
+
exitCode,
|
|
445
|
+
failureKind,
|
|
446
|
+
});
|
|
392
447
|
}
|
|
393
448
|
else {
|
|
394
|
-
resolve({
|
|
449
|
+
resolve({
|
|
450
|
+
success: true,
|
|
451
|
+
output,
|
|
452
|
+
stdout,
|
|
453
|
+
stderr,
|
|
454
|
+
exitCode: 0,
|
|
455
|
+
});
|
|
395
456
|
}
|
|
396
457
|
});
|
|
397
458
|
});
|
package/dist/wp-client.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Generate one at: WP Admin → Users → Your Profile → Application Passwords.
|
|
6
6
|
*/
|
|
7
7
|
import { type HandshakeResult } from './compatibility.js';
|
|
8
|
+
import { type DiviopsResponse } from './envelope.js';
|
|
8
9
|
export interface WPClientConfig {
|
|
9
10
|
siteUrl: string;
|
|
10
11
|
username: string;
|
|
@@ -22,8 +23,45 @@ export declare class WPClient {
|
|
|
22
23
|
body?: Record<string, unknown>;
|
|
23
24
|
params?: Record<string, string>;
|
|
24
25
|
}): Promise<T>;
|
|
26
|
+
/**
|
|
27
|
+
* Envelope-aware sibling of `request()`.
|
|
28
|
+
*
|
|
29
|
+
* Issue the same HTTP call as `request()`, but return the parsed body
|
|
30
|
+
* directly as a `DiviopsResponse<T>` without throwing on envelope errors:
|
|
31
|
+
*
|
|
32
|
+
* - Body is an envelope (`{ok: true, data}` or `{ok: false, error}`)
|
|
33
|
+
* → return it verbatim. Plugin-emitted error envelopes (typically
|
|
34
|
+
* 4xx with `{ok: false, error: {code, message, hint?}}`) flow back
|
|
35
|
+
* to the caller as a typed result, not a throw.
|
|
36
|
+
* - Response is non-2xx and body is NOT an envelope (e.g. a WP REST
|
|
37
|
+
* framework error before the route runs, an unexpected 5xx)
|
|
38
|
+
* → synthesize `{ok: false, error: {code: 'wp_error', message: ...}}`
|
|
39
|
+
* so callers see a uniform shape regardless of upstream.
|
|
40
|
+
* - Response is 2xx but body is not enveloped (legacy routes that
|
|
41
|
+
* have not adopted yet) → wrap as `{ok: true, data: <body>}` to
|
|
42
|
+
* preserve a single contract for adopting tools to consume.
|
|
43
|
+
*
|
|
44
|
+
* Transport errors (network, JSON parse failure on a 2xx body) still
|
|
45
|
+
* throw — those are not domain-level outcomes the envelope models.
|
|
46
|
+
*
|
|
47
|
+
* Migration: pilot's three `schema_*` tools and every subsequent
|
|
48
|
+
* namespace adoption use this method. Once the rollout completes,
|
|
49
|
+
* `request()` becomes orphan and is removed.
|
|
50
|
+
*/
|
|
51
|
+
requestEnveloped<T = unknown>(endpoint: string, options?: {
|
|
52
|
+
method?: string;
|
|
53
|
+
body?: Record<string, unknown>;
|
|
54
|
+
params?: Record<string, string>;
|
|
55
|
+
}): Promise<DiviopsResponse<T>>;
|
|
25
56
|
/**
|
|
26
57
|
* Test the connection to WordPress.
|
|
58
|
+
*
|
|
59
|
+
* Routes through `requestEnveloped` because `/schema/settings` was
|
|
60
|
+
* envelope-adopted in the schema_* pilot — its body is now
|
|
61
|
+
* `{ ok: true, data: { builder, ... } }`. Reading
|
|
62
|
+
* `result.builder.version` against that shape (the pre-pilot pattern)
|
|
63
|
+
* silently regresses meta_ping to "Connected to Divi unknown" on
|
|
64
|
+
* healthy sites.
|
|
27
65
|
*/
|
|
28
66
|
testConnection(): Promise<{
|
|
29
67
|
ok: boolean;
|
package/dist/wp-client.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
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 { ErrorCodes, isEnveloped, } from './envelope.js';
|
|
7
8
|
/**
|
|
8
9
|
* Normalize quote-escape pathologies inside `$variable(...)$` token regions only.
|
|
9
10
|
*
|
|
@@ -141,15 +142,159 @@ export class WPClient {
|
|
|
141
142
|
}
|
|
142
143
|
return response.json();
|
|
143
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Envelope-aware sibling of `request()`.
|
|
147
|
+
*
|
|
148
|
+
* Issue the same HTTP call as `request()`, but return the parsed body
|
|
149
|
+
* directly as a `DiviopsResponse<T>` without throwing on envelope errors:
|
|
150
|
+
*
|
|
151
|
+
* - Body is an envelope (`{ok: true, data}` or `{ok: false, error}`)
|
|
152
|
+
* → return it verbatim. Plugin-emitted error envelopes (typically
|
|
153
|
+
* 4xx with `{ok: false, error: {code, message, hint?}}`) flow back
|
|
154
|
+
* to the caller as a typed result, not a throw.
|
|
155
|
+
* - Response is non-2xx and body is NOT an envelope (e.g. a WP REST
|
|
156
|
+
* framework error before the route runs, an unexpected 5xx)
|
|
157
|
+
* → synthesize `{ok: false, error: {code: 'wp_error', message: ...}}`
|
|
158
|
+
* so callers see a uniform shape regardless of upstream.
|
|
159
|
+
* - Response is 2xx but body is not enveloped (legacy routes that
|
|
160
|
+
* have not adopted yet) → wrap as `{ok: true, data: <body>}` to
|
|
161
|
+
* preserve a single contract for adopting tools to consume.
|
|
162
|
+
*
|
|
163
|
+
* Transport errors (network, JSON parse failure on a 2xx body) still
|
|
164
|
+
* throw — those are not domain-level outcomes the envelope models.
|
|
165
|
+
*
|
|
166
|
+
* Migration: pilot's three `schema_*` tools and every subsequent
|
|
167
|
+
* namespace adoption use this method. Once the rollout completes,
|
|
168
|
+
* `request()` becomes orphan and is removed.
|
|
169
|
+
*/
|
|
170
|
+
async requestEnveloped(endpoint, options = {}) {
|
|
171
|
+
const { method = 'GET', body, params } = options;
|
|
172
|
+
let url = `${this.baseUrl}/wp-json/diviops/v1${endpoint}`;
|
|
173
|
+
if (params) {
|
|
174
|
+
const searchParams = new URLSearchParams(params);
|
|
175
|
+
url += `?${searchParams.toString()}`;
|
|
176
|
+
}
|
|
177
|
+
const fetchOptions = {
|
|
178
|
+
method,
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: this.authHeader,
|
|
181
|
+
'Content-Type': 'application/json',
|
|
182
|
+
Accept: 'application/json',
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
if (body && method !== 'GET') {
|
|
186
|
+
fetchOptions.body = JSON.stringify(normalizeBody(body));
|
|
187
|
+
}
|
|
188
|
+
const response = await fetch(url, fetchOptions);
|
|
189
|
+
const rawBody = await response.text();
|
|
190
|
+
// Try to parse the body as JSON. Failure is recoverable for non-2xx
|
|
191
|
+
// responses (HTML/plain-text error pages from a misconfigured host or
|
|
192
|
+
// upstream proxy synthesize a `wp_error` envelope rather than throwing)
|
|
193
|
+
// and is fatal only on a 2xx body that promised JSON but didn't deliver.
|
|
194
|
+
let parsed;
|
|
195
|
+
let parseError = null;
|
|
196
|
+
try {
|
|
197
|
+
parsed = rawBody === '' ? null : JSON.parse(rawBody);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
parseError = e;
|
|
201
|
+
}
|
|
202
|
+
if (parseError === null && isEnveloped(parsed)) {
|
|
203
|
+
return parsed;
|
|
204
|
+
}
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
// Non-2xx + body either non-JSON or non-enveloped JSON. Two sub-cases:
|
|
207
|
+
//
|
|
208
|
+
// (1) Body is a parsed `WP_Error`-shaped JSON object — `{code, message,
|
|
209
|
+
// data?: {status?, hint?}}`. This is what older diviops-agent
|
|
210
|
+
// versions (pre-envelope-adoption) emit alongside `WP_Error`-based
|
|
211
|
+
// handlers, and what the WP REST framework itself emits for
|
|
212
|
+
// framework-level errors (`rest_forbidden`, `rest_no_route`,
|
|
213
|
+
// `rest_invalid_param`, etc.). Promote `code` to the envelope's
|
|
214
|
+
// `error.code` so callers running against a mixed-version
|
|
215
|
+
// deployment (new server + older plugin emitting non-envelope
|
|
216
|
+
// error bodies) still receive granular codes like `invalid_type`,
|
|
217
|
+
// `not_found`, `rest_forbidden` — instead of having every legacy
|
|
218
|
+
// 4xx collapse to a generic `wp_error` and lose the upstream
|
|
219
|
+
// slug. Hint is forwarded when present in `data.hint` (matches
|
|
220
|
+
// the convention `envelope_from_wp_error` writes plugin-side).
|
|
221
|
+
//
|
|
222
|
+
// (2) Body is non-JSON, or JSON without `code`/`message` (HTML/plain
|
|
223
|
+
// error pages from a misconfigured host, host firewall pages,
|
|
224
|
+
// 502/504 from a reverse proxy, etc.) — fall back to a synthesized
|
|
225
|
+
// `wp_error` so adopted tools still see a uniform envelope.
|
|
226
|
+
const isLegacyWpErrorBody = parseError === null &&
|
|
227
|
+
parsed !== null &&
|
|
228
|
+
typeof parsed === 'object' &&
|
|
229
|
+
typeof parsed.code === 'string' &&
|
|
230
|
+
typeof parsed.message === 'string';
|
|
231
|
+
if (isLegacyWpErrorBody) {
|
|
232
|
+
const obj = parsed;
|
|
233
|
+
const out = {
|
|
234
|
+
code: obj.code,
|
|
235
|
+
message: obj.message,
|
|
236
|
+
};
|
|
237
|
+
const data = obj.data;
|
|
238
|
+
if (data !== null &&
|
|
239
|
+
typeof data === 'object' &&
|
|
240
|
+
'hint' in data &&
|
|
241
|
+
typeof data.hint === 'string') {
|
|
242
|
+
out.hint = data.hint;
|
|
243
|
+
}
|
|
244
|
+
if (response.status === 429) {
|
|
245
|
+
const retryAfter = response.headers.get('Retry-After') || '60';
|
|
246
|
+
out.message = `${out.message} (retry after ${retryAfter}s)`;
|
|
247
|
+
}
|
|
248
|
+
return { ok: false, error: out };
|
|
249
|
+
}
|
|
250
|
+
const messageFromBody = parseError
|
|
251
|
+
? rawBody.slice(0, 200)
|
|
252
|
+
: parsed && typeof parsed === 'object' && parsed !== null && 'message' in parsed
|
|
253
|
+
? String(parsed.message)
|
|
254
|
+
: rawBody;
|
|
255
|
+
let message = `WordPress API error (${response.status}): ${messageFromBody}`;
|
|
256
|
+
if (response.status === 429) {
|
|
257
|
+
const retryAfter = response.headers.get('Retry-After') || '60';
|
|
258
|
+
message = `Rate limited (${response.status}): ${messageFromBody} (retry after ${retryAfter}s)`;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
error: { code: ErrorCodes.WP_ERROR, message },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
// 2xx with non-JSON body — only legitimate failure mode that warrants a
|
|
266
|
+
// throw. A successful HTTP status with garbage in the body is a server-
|
|
267
|
+
// side contract violation, not a domain-level outcome the envelope can
|
|
268
|
+
// represent.
|
|
269
|
+
if (parseError) {
|
|
270
|
+
throw new Error(`WordPress API non-JSON body (${response.status}): ${rawBody.slice(0, 200)}`);
|
|
271
|
+
}
|
|
272
|
+
// 2xx body that is not yet shaped as an envelope — legacy route. Wrap
|
|
273
|
+
// it so adopting tools always see a uniform success shape.
|
|
274
|
+
return { ok: true, data: parsed };
|
|
275
|
+
}
|
|
144
276
|
/**
|
|
145
277
|
* Test the connection to WordPress.
|
|
278
|
+
*
|
|
279
|
+
* Routes through `requestEnveloped` because `/schema/settings` was
|
|
280
|
+
* envelope-adopted in the schema_* pilot — its body is now
|
|
281
|
+
* `{ ok: true, data: { builder, ... } }`. Reading
|
|
282
|
+
* `result.builder.version` against that shape (the pre-pilot pattern)
|
|
283
|
+
* silently regresses meta_ping to "Connected to Divi unknown" on
|
|
284
|
+
* healthy sites.
|
|
146
285
|
*/
|
|
147
286
|
async testConnection() {
|
|
148
287
|
try {
|
|
149
|
-
const
|
|
288
|
+
const response = await this.requestEnveloped('/schema/settings');
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
message: `Connection failed: [${response.error.code}] ${response.error.message}`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
150
295
|
return {
|
|
151
296
|
ok: true,
|
|
152
|
-
message: `Connected to Divi ${
|
|
297
|
+
message: `Connected to Divi ${response.data.builder?.version ?? 'unknown'}`,
|
|
153
298
|
};
|
|
154
299
|
}
|
|
155
300
|
catch (error) {
|